diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 36d7a69..6d676ea 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:mobile/providers/basic_providers.dart'; import 'package:provider/provider.dart'; import 'core/constants.dart'; import 'controllers/auth.dart'; @@ -12,6 +13,7 @@ void main() async { runApp( MultiProvider( providers: [ + ChangeNotifierProvider(create: (_) => BasicProviders()), ChangeNotifierProvider(create: (_) => AuthState()), ChangeNotifierProvider(create: (_) => ChatController()), ], diff --git a/mobile/lib/pages/home_page.dart b/mobile/lib/pages/home_page.dart index e751bb9..7adb89c 100644 --- a/mobile/lib/pages/home_page.dart +++ b/mobile/lib/pages/home_page.dart @@ -1,6 +1,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:mobile/controllers/chat.dart'; +import 'package:mobile/pages/settings%20pages/accounts.dart'; +import 'package:mobile/pages/settings_page.dart'; +import 'package:mobile/providers/basic_providers.dart'; import 'package:mobile/services/auth.dart'; import 'package:mobile/widgets/custom_cards.dart'; import 'package:provider/provider.dart'; @@ -17,6 +20,9 @@ class _HomePageState extends State { late bool _isSearchOpen; int _page = 1; final _searchController = TextEditingController(); + final Set _selectedChatIds = {}; + + bool get _isSelectionMode => _selectedChatIds.isNotEmpty; @override void initState() { @@ -26,7 +32,6 @@ class _HomePageState extends State { final chatController = context.read(); final authService = AuthService(); final token = await authService.getToken(); - if (token != null) { // Initialize WebSocket session immediately on app load await chatController.initSession(token); @@ -101,8 +106,9 @@ class _HomePageState extends State { ); } - Widget buildGlassNavigationBar() { + Widget buildGlassNavigationBar({Key? key}) { return SafeArea( + key: key, child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 20), child: ClipRRect( @@ -204,15 +210,10 @@ class _HomePageState extends State { ); } - final Set _selectedChatIds = {}; - - bool get _isSelectionMode => _selectedChatIds.isNotEmpty; - Widget buildHomeTab(ChatController chatState) { - return RefreshIndicator( color: const Color(0xFF1890FF), - onRefresh: () => chatState.loadInbox(), + onRefresh: _isSelectionMode ? () async {} : () => chatState.loadInbox(), child: CustomScrollView( slivers: [ SliverAppBar( @@ -230,84 +231,180 @@ class _HomePageState extends State { child: Container(color: Colors.white.withValues(alpha: 0.4)), ), ), - leading: const Padding( - padding: EdgeInsets.only(left: 16.0, top: 10.0, bottom: 10.0), - child: CircleAvatar( - radius: 18, - backgroundColor: Color(0xFFD6E4FF), - child: Icon(Icons.person, color: Color(0xFF1890FF), size: 18), - ), - ), - leadingWidth: 52, - title: AnimatedSwitcher( - switchInCurve: Curves.decelerate, - switchOutCurve: Curves.decelerate, - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - final tween = Tween( - begin: const Offset(0.5, 0.0), - end: Offset.zero, - ); - final offsetAnimation = tween.animate(animation); - return SlideTransition(position: offsetAnimation, child: child); - }, - child: _isSearchOpen - ? SizedBox( - height: 40, - child: TextField( - controller: _searchController, - key: const ValueKey('search'), - autofocus: true, - style: const TextStyle(color: Colors.black87), - decoration: InputDecoration( - hintText: 'Search conversations...', - hintStyle: const TextStyle(color: Colors.black38), - filled: true, - fillColor: Colors.black.withValues(alpha: 0.05), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 0, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, + leading: _isSelectionMode + ? null + : InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AccountsSettings(), + ), + ); + }, + child: Hero( + tag: 'User Profile', + child: const Padding( + padding: EdgeInsets.only( + left: 16.0, + top: 10.0, + bottom: 10.0, + ), + child: CircleAvatar( + radius: 18, + backgroundColor: Color(0xFFD6E4FF), + child: Icon( + Icons.person, + color: Color(0xFF1890FF), + size: 18, ), ), ), - ) - : const Text( - 'Elephant', - style: TextStyle( - letterSpacing: 0.5, - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - key: ValueKey('title'), + ), + ), + leadingWidth: 52, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _isSelectionMode + ? Text('${_selectedChatIds.length} Selected') + : AnimatedSwitcher( + switchInCurve: Curves.decelerate, + switchOutCurve: Curves.decelerate, + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + final tween = Tween( + begin: const Offset(0.5, 0.0), + end: Offset.zero, + ); + final offsetAnimation = tween.animate(animation); + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + child: _isSearchOpen + ? SizedBox( + height: 40, + child: TextField( + controller: _searchController, + key: const ValueKey('search'), + autofocus: true, + style: const TextStyle(color: Colors.black87), + decoration: InputDecoration( + hintText: 'Search conversations...', + hintStyle: const TextStyle( + color: Colors.black38, + ), + filled: true, + fillColor: Colors.black.withValues( + alpha: 0.05, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 0, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ) + : const Text( + 'Elephant', + style: TextStyle( + letterSpacing: 0.5, + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + key: ValueKey('title'), + ), ), ), actions: [ - IconButton( - onPressed: () { - setState(() { - _isSearchOpen = !_isSearchOpen; - if (!_isSearchOpen) _searchController.clear(); - }); - }, - icon: Icon( - _isSearchOpen ? Icons.close : Icons.search, - color: Colors.black87, - ), - ), + _isSelectionMode + ? const SizedBox.shrink() + : IconButton( + onPressed: () { + setState(() { + _isSearchOpen = !_isSearchOpen; + if (!_isSearchOpen) _searchController.clear(); + }); + }, + icon: Icon( + _isSearchOpen ? Icons.close : Icons.search, + color: Colors.black87, + ), + ), if (!_isSearchOpen) - IconButton( - icon: const Icon(Icons.more_vert, color: Colors.black87), - onPressed: () {}, - ), + !_isSelectionMode + ? PopupMenuButton( + onSelected: (String value) { + switch (value) { + case 'settings': + context + .read() + .initNotificationsSwitch(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsPage(), + ), + ); + break; + } + }, + borderRadius: BorderRadius.circular(20), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'starred', + child: Text('Starred messages'), + ), + const PopupMenuItem( + value: 'read all', + child: Text('Read all'), + ), + const PopupMenuItem( + value: 'settings', + child: Text('Settings'), + ), + ], + ) + : PopupMenuButton( + onSelected: (String value) { + switch (value) { + case 'Select all': + setState(() { + for (final thread in chatState.inbox) { + _selectedChatIds.add(thread.chatUserId); + } + }); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'Select all', + child: Text("Select all"), + ), + const PopupMenuItem( + value: 'Favorite', + child: Text("Favorite"), + ), + const PopupMenuItem( + value: 'Read', + child: Text("Read"), + ), + const PopupMenuItem( + value: 'Delete', + child: Text("Delete"), + ), + ], + ), const SizedBox(width: 4), ], ), - chatState.inbox.isEmpty ? const SliverFillRemaining( hasScrollBody: false, @@ -322,8 +419,31 @@ class _HomePageState extends State { itemCount: chatState.inbox.length, itemBuilder: (context, index) { final thread = chatState.inbox[index]; - final bool isSelected = _selectedChatIds.contains(thread.chatUserId); - return CustomChatCard(conversation: thread, isSelected: isSelected); + final bool isSelected = _selectedChatIds.contains( + thread.chatUserId, + ); + + return CustomChatCard( + isSelectionMode: _isSelectionMode, + conversation: thread, + isSelected: isSelected, + onLongPress: () { + if (!_isSearchOpen) { + setState(() { + _selectedChatIds.add(thread.chatUserId); + }); + } + }, + onTapInSelection: () { + setState(() { + if (_selectedChatIds.contains(thread.chatUserId)) { + _selectedChatIds.remove(thread.chatUserId); + } else { + _selectedChatIds.add(thread.chatUserId); + } + }); + }, + ); }, ), SliverList( @@ -333,49 +453,72 @@ class _HomePageState extends State { ), ); } + @override Widget build(BuildContext context) { final chatState = context.watch(); - return Scaffold( - backgroundColor: Colors.white, - extendBodyBehindAppBar: true, - extendBody: true, - body: IndexedStack( - index: _page, - children: [ - const Center( - child: Text( - "No stories available", - style: TextStyle(color: Colors.black54, fontSize: 16), + return PopScope( + canPop: _selectedChatIds.isEmpty, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + setState(() { + _selectedChatIds.clear(); + }); + }, + child: Scaffold( + backgroundColor: Colors.white, + extendBodyBehindAppBar: true, + extendBody: true, + body: IndexedStack( + index: _page, + children: [ + const Center( + child: Text( + "No stories available", + style: TextStyle(color: Colors.black54, fontSize: 16), + ), ), - ), - buildHomeTab(chatState), - const Center( - child: Text( - "No recent calls", - style: TextStyle(color: Colors.black54, fontSize: 16), + buildHomeTab(chatState), + const Center( + child: Text( + "No recent calls", + style: TextStyle(color: Colors.black54, fontSize: 16), + ), ), - ), - ], + ], + ), + floatingActionButton: _page == 1 + ? Padding( + padding: const EdgeInsets.only(bottom: 10), + child: FloatingActionButton( + heroTag: "fab_pen", + backgroundColor: const Color(0xFF1890FF), + child: const Icon(Icons.edit, color: Colors.white), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NewChatPage()), + ); + }, + ), + ) + : null, + bottomNavigationBar: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: (child, animation) { + final tween = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ); + final offsetAnimation = tween.animate(animation); + return SlideTransition(position: offsetAnimation, child: child); + }, + child: _isSelectionMode + ? const SizedBox.shrink(key: ValueKey('nav_hidden')) + : buildGlassNavigationBar(key: const ValueKey('nav_visible')), + ), ), - floatingActionButton: _page == 1 - ? Padding( - padding: const EdgeInsets.only(bottom: 75), - child: FloatingActionButton( - heroTag: "fab_pen", - backgroundColor: const Color(0xFF1890FF), - child: const Icon(Icons.edit, color: Colors.white), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NewChatPage()), - ); - }, - ), - ) - : null, - bottomNavigationBar: buildGlassNavigationBar(), ); } } diff --git a/mobile/lib/pages/profile_setup_page.dart b/mobile/lib/pages/profile_setup_page.dart index 4f377c8..57161f5 100644 --- a/mobile/lib/pages/profile_setup_page.dart +++ b/mobile/lib/pages/profile_setup_page.dart @@ -78,7 +78,7 @@ class ProfileSetupPage extends StatelessWidget { context, MaterialPageRoute(builder: (context) => const HomePage()), ); - context.read().logIn(); + context.read().logInSave(); }, child: Row( mainAxisAlignment: .center, diff --git a/mobile/lib/pages/settings pages/accounts.dart b/mobile/lib/pages/settings pages/accounts.dart index 0d949c3..0e81b13 100644 --- a/mobile/lib/pages/settings pages/accounts.dart +++ b/mobile/lib/pages/settings pages/accounts.dart @@ -1,10 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:mobile/controllers/auth.dart'; +import 'package:mobile/main.dart'; +import 'package:provider/provider.dart'; class AccountsSettings extends StatelessWidget { const AccountsSettings({super.key}); @override Widget build(BuildContext context) { - return Scaffold(appBar: AppBar(title: Text('Accounts'))); + return Scaffold( + appBar: AppBar(title: Text('Accounts')), + body: ListView( + children: [ + SizedBox( + width: double.infinity, + height: 150, + child: Hero( + tag: 'User Profile', + child: const Padding( + padding: EdgeInsets.only(left: 16.0, top: 10.0, bottom: 10.0), + child: CircleAvatar( + backgroundColor: Color(0xFFD6E4FF), + child: Icon(Icons.person, color: Color(0xFF1890FF)), + ), + ), + ), + ), + SizedBox(height: 200), + FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.red), + ), + onPressed: () { + context.read().logout(); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => SessionGateway()), + (route) => false, + ); + }, + child: Text('Log out'), + ), + ], + ), + ); } } diff --git a/mobile/lib/pages/settings_page.dart b/mobile/lib/pages/settings_page.dart index f7b371f..6f8d6d6 100644 --- a/mobile/lib/pages/settings_page.dart +++ b/mobile/lib/pages/settings_page.dart @@ -108,12 +108,14 @@ class SettingsPage extends StatelessWidget { icon: Icons.notifications, settingName: 'Notifications', whereTo: NotificationsSettingsPage(), - trailing: Switch( - value: context - .watch() - .notificationsSwitch, - onChanged: (value) { - context.read().toggleNotifications(); + trailing: Consumer( + builder: (context, basicProvider, child) { + return Switch( + value: basicProvider.notificationsSwitch, + onChanged: (value) { + basicProvider.toggleNotifications(); + }, + ); }, ), ), diff --git a/mobile/lib/providers/basic_providers.dart b/mobile/lib/providers/basic_providers.dart index 3136437..e4d752c 100644 --- a/mobile/lib/providers/basic_providers.dart +++ b/mobile/lib/providers/basic_providers.dart @@ -12,9 +12,18 @@ class BasicProviders extends ChangeNotifier { bool get notificationsSwitch => _notificationsSwitch; - void toggleNotifications(){ + void initNotificationsSwitch() async { + final pref = await SharedPreferences.getInstance(); + _notificationsSwitch = pref.getBool('notificationToggle') ?? false; + notifyListeners(); + } + + void toggleNotifications() async { _notificationsSwitch = !_notificationsSwitch; notifyListeners(); + + final pref = await SharedPreferences.getInstance(); + pref.setBool('notificationToggle', _notificationsSwitch); } bool get isInitialized => _isInitialized; @@ -35,7 +44,7 @@ class BasicProviders extends ChangeNotifier { notifyListeners(); } - Future logIn() async { + Future logInSave() async { _hasLoggedIn = true; notifyListeners(); @@ -43,7 +52,7 @@ class BasicProviders extends ChangeNotifier { await pref.setBool('hasLoggedIn', true); } - Future logOut() async { + Future logOutSave() async { _hasLoggedIn = false; notifyListeners(); diff --git a/mobile/lib/widgets/chat_screen_modular_widgets.dart b/mobile/lib/widgets/chat_screen_modular_widgets.dart index 368fc7d..0a75cd9 100644 --- a/mobile/lib/widgets/chat_screen_modular_widgets.dart +++ b/mobile/lib/widgets/chat_screen_modular_widgets.dart @@ -240,14 +240,10 @@ class _ChatInputAreaState extends State { keyboardType: TextInputType.multiline, minLines: 1, maxLines: 5, - - // 1. Set the limit maxLength: _charLimit, - // 2. CRITICAL: This allows them to keep typing past the limit so it shows "7000/5000" maxLengthEnforcement: .none, - + // cursorColor: Colors.blue, style: TextStyle( - // 3. Turn the typed text red if they exceed the limit color: _isOverLimit ? Colors.red : Colors.black87, fontSize: 15, ), @@ -259,7 +255,9 @@ class _ChatInputAreaState extends State { ? Colors.red.withValues(alpha: 0.1) : const Color(0xFFF5F5F5), filled: true, - errorText: _isOverLimit ? 'character limit exceeded' : '', + errorText: _isOverLimit + ? 'character limit exceeded' + : null, counterStyle: TextStyle( color: _isOverLimit ? Colors.red : Colors.black54, fontWeight: _isOverLimit diff --git a/mobile/lib/widgets/custom_cards.dart b/mobile/lib/widgets/custom_cards.dart index 3d4376f..f6a8c24 100644 --- a/mobile/lib/widgets/custom_cards.dart +++ b/mobile/lib/widgets/custom_cards.dart @@ -7,10 +7,18 @@ import '../controllers/chat.dart'; class CustomChatCard extends StatelessWidget { final Conversation conversation; final bool isSelected; - final Function? onLongPress; - final Function? onTap; + final bool isSelectionMode; + final GestureLongPressCallback? onLongPress; + final Function onTapInSelection; - const CustomChatCard({super.key, required this.conversation, required this.isSelected, this.onLongPress, this.onTap}); + const CustomChatCard({ + super.key, + required this.conversation, + required this.isSelected, + this.onLongPress, + required this.onTapInSelection, + required this.isSelectionMode, + }); @override Widget build(BuildContext context) { @@ -18,34 +26,55 @@ class CustomChatCard extends StatelessWidget { "${conversation.lastMessageTime.hour.toString().padLeft(2, '0')}:${conversation.lastMessageTime.minute.toString().padLeft(2, '0')}"; return InkWell( - onLongPress: () { - - }, + onLongPress: onLongPress, onTap: () async { - final controller = context.read(); - await controller.openChat(conversation.chatUserId); + if (isSelectionMode) { + onTapInSelection(); + } else { + final controller = context.read(); + await controller.openChat(conversation.chatUserId); - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatPage( - chatUserId: conversation.chatUserId, - displayName: conversation.displayName, + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatPage( + chatUserId: conversation.chatUserId, + displayName: conversation.displayName, + ), ), - ), - ); + ); + } } }, - child: Padding( + child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + color: isSelected? Colors.blue.withAlpha(20) : null, child: Row( children: [ - const CircleAvatar( - radius: 24, - backgroundColor: Color(0xFFD6E4FF), - child: Icon(Icons.person, color: Color(0xFF1890FF)), + SizedBox( + width: 50, + height: 50, + child: Stack( + fit: StackFit.expand, + children: [ + const CircleAvatar( + radius: 24, + backgroundColor: Color(0xFFD6E4FF), + child: Icon(Icons.person, color: Color(0xFF1890FF)), + ), + isSelected + ? Align( + alignment: AlignmentGeometry.bottomRight, + child: const Icon( + Icons.check_circle, + color: Colors.blue, + ), + ) + : SizedBox.shrink(), + ], + ), ), const SizedBox(width: 14), Expanded(