diff --git a/lib/components/chat_bubble.dart b/lib/components/chat_bubble.dart new file mode 100644 index 0000000..38f737e --- /dev/null +++ b/lib/components/chat_bubble.dart @@ -0,0 +1,37 @@ +import 'package:cofounderella/themes/theme_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ChatBubble extends StatelessWidget { + final String message; + final bool isCurrentUser; + + const ChatBubble({ + super.key, + required this.message, + required this.isCurrentUser, + }); + + @override + Widget build(BuildContext context) { + // control bubble color according to light or dark mode + bool isDarkMode = + Provider.of(context, listen: false).isDarkMode; + + return Container( + decoration: BoxDecoration( + // TODO check colors for bubbles and text below + color: isCurrentUser + ? (isDarkMode ? Colors.green.shade600 : Colors.green.shade500) + : (isDarkMode ? Colors.grey.shade300 : Colors.grey.shade700), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.symmetric(vertical: 2.5, horizontal: 25), + child: Text( + message, + style: TextStyle(color: isDarkMode ? Colors.white : Colors.black), + ), + ); + } +} diff --git a/lib/components/my_drawer.dart b/lib/components/my_drawer.dart index 9d7838a..1c499e3 100644 --- a/lib/components/my_drawer.dart +++ b/lib/components/my_drawer.dart @@ -1,4 +1,4 @@ -import 'package:cofounderella/auth/auth_service.dart'; +import 'package:cofounderella/services/auth/auth_service.dart'; import 'package:cofounderella/pages/settings_page.dart'; import 'package:flutter/material.dart'; diff --git a/lib/components/my_textfield.dart b/lib/components/my_textfield.dart index b8c8cfb..ced49ac 100644 --- a/lib/components/my_textfield.dart +++ b/lib/components/my_textfield.dart @@ -2,14 +2,16 @@ import 'package:flutter/material.dart'; class MyTextField extends StatelessWidget { final String hintText; - final bool hideText; + final bool obscureText; final TextEditingController controller; + final FocusNode? focusNode; const MyTextField({ super.key, required this.hintText, - required this.hideText, + required this.obscureText, required this.controller, + this.focusNode, }); @override @@ -17,8 +19,9 @@ class MyTextField extends StatelessWidget { return Padding( padding: const EdgeInsets.all(25.0), child: TextField( - obscureText: hideText, + obscureText: obscureText, controller: controller, + focusNode: focusNode, decoration: InputDecoration( enabledBorder: OutlineInputBorder( borderSide: diff --git a/lib/components/user_tile.dart b/lib/components/user_tile.dart new file mode 100644 index 0000000..f5b7fa7 --- /dev/null +++ b/lib/components/user_tile.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class UserTile extends StatelessWidget { + final String text; + final void Function()? onTap; + + const UserTile({super.key, required this.text, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 25), + padding: const EdgeInsets.all(20), + child: Row( + children: [ + // icon + const Icon(Icons.person), + + const SizedBox(width: 20), + + // user name + Text(text), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index af2a05f..42e2df6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,10 @@ -import 'package:cofounderella/auth/auth_gate.dart'; -import 'package:cofounderella/themes/light_mode.dart'; +import 'package:cofounderella/services/auth/auth_gate.dart'; +import 'package:cofounderella/themes/theme_provider.dart'; import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:provider/provider.dart'; import 'firebase_options.dart'; - void main() async { // Firebase stuff WidgetsFlutterBinding.ensureInitialized(); @@ -12,7 +12,12 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); // Standard stuff - runApp(const MyApp()); + runApp( + ChangeNotifierProvider( + create: (context) => ThemeProvider(), + child: const MyApp(), + ), + ); } class MyApp extends StatelessWidget { @@ -24,10 +29,8 @@ class MyApp extends StatelessWidget { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Flutter Demo', // TODO change title - theme: lightMode, + theme: Provider.of(context).themeData, home: const AuthGate(), ); } } - - diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..78920ac --- /dev/null +++ b/lib/models/message.dart @@ -0,0 +1,28 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class Message { + final String senderID; + final String senderEmail; + final String receiverID; + final String message; + final Timestamp timestamp; + + Message({ + required this.senderID, + required this.senderEmail, + required this.receiverID, + required this.message, + required this.timestamp, + }); + + // convert to a map + Map toMap() { + return { + 'senderID': senderID, + 'senderEmail': senderEmail, + 'receiverID': receiverID, + 'message': message, + 'timestamp': timestamp, + }; + } +} diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart new file mode 100644 index 0000000..5cd2c97 --- /dev/null +++ b/lib/pages/chat_page.dart @@ -0,0 +1,185 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cofounderella/components/chat_bubble.dart'; +import 'package:cofounderella/components/my_textfield.dart'; +import 'package:cofounderella/services/auth/auth_service.dart'; +import 'package:cofounderella/services/chat/chat_service.dart'; +import 'package:flutter/material.dart'; + +class ChatPage extends StatefulWidget { + final String receiverEmail; + final String receiverID; + + const ChatPage({ + super.key, + required this.receiverEmail, + required this.receiverID, + }); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + // text controller + final TextEditingController _messageController = TextEditingController(); + + // chat and auth services + final ChatService _chatService = ChatService(); + final AuthService _authService = AuthService(); + + // for textfield focus + FocusNode myFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + // add listener to focus node + myFocusNode.addListener(() { + if (myFocusNode.hasFocus) { + // cause a delay so that the keyboard has time to show up + // then the amount of remaining space will be calculated + // then scroll down + Future.delayed( + const Duration(milliseconds: 500), + () => scrollDown(), + ); + } + }); + + // wait a bit for listview to be built, then scroll to bottom + Future.delayed( + const Duration(milliseconds: 500), + () => scrollDown(), + ); + } + + @override + void dispose() { + myFocusNode.dispose(); + _messageController.dispose(); + super.dispose(); + } + + // scroll controller + final ScrollController _scrollController = ScrollController(); + void scrollDown() { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(seconds: 1), + curve: Curves.fastOutSlowIn, + ); + } + + void sendMessage() async { + // send message if not empty + if (_messageController.text.isNotEmpty) { + await _chatService.sendMessage( + widget.receiverID, _messageController.text); + + // clear text controller + _messageController.clear(); + } + + scrollDown(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.receiverEmail)), + body: Column( + children: [ + // display all messages + Expanded( + child: _buildMessageList(), + ), + + // user input + _buildUserInput(), + ], + ), + ); + } + + Widget _buildMessageList() { + String senderID = _authService.getCurrentUser()!.uid; + return StreamBuilder( + stream: _chatService.getMessages(senderID, widget.receiverID), + builder: (context, snapshot) { + // errors + if (snapshot.hasError) { + return Text("Error: ${snapshot.error}"); + } + + // loading + if (snapshot.connectionState == ConnectionState.waiting) { + return const Text("Loading.."); + } + + // return list view + return ListView( + controller: _scrollController, + children: + snapshot.data!.docs.map((doc) => _buildMessageItem(doc)).toList(), + ); + }, + ); + } + + Widget _buildMessageItem(DocumentSnapshot doc) { + Map data = doc.data() as Map; + + // align message to the right if sender is current user, otherwise left + bool isCurrentUser = data['senderID'] == _authService.getCurrentUser()!.uid; + var alignment = + isCurrentUser ? Alignment.centerRight : Alignment.centerLeft; + + return Container( + alignment: alignment, + child: Column( + crossAxisAlignment: + isCurrentUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + ChatBubble( + message: data["message"], + isCurrentUser: isCurrentUser, + ), + ], + ), + ); + } + + Widget _buildUserInput() { + return Padding( + padding: const EdgeInsets.only(bottom: 50.0), + child: Row( + children: [ + // text should take up most of space + Expanded( + child: MyTextField( + controller: _messageController, + hintText: "Type a message", + obscureText: false, // TODO make this optional + focusNode: myFocusNode, + ), + ), + + // send button + Container( + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + margin: const EdgeInsets.only(right: 25), + child: IconButton( + onPressed: sendMessage, + icon: const Icon(Icons.send), + color: Colors.white, // TODO check colors + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 7b5632a..0787d78 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,91 +1,80 @@ import 'package:cofounderella/components/my_drawer.dart'; +import 'package:cofounderella/components/user_tile.dart'; +import 'package:cofounderella/services/auth/auth_service.dart'; +import 'package:cofounderella/services/chat/chat_service.dart'; import 'package:flutter/material.dart'; -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +import 'chat_page.dart'; - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. +class HomePage extends StatelessWidget { + HomePage({super.key}); - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } + // chat and auth service + final ChatService _chatService = ChatService(); + final AuthService _authService = AuthService(); @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. return Scaffold( appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.primary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), + title: const Text("Home"), + // TODO appbar style: remove background and set elevation to 0 ? + backgroundColor: Colors.transparent, + foregroundColor: Colors.grey.shade800, + elevation: 0, ), drawer: const MyDrawer(), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + body: _buildUserList(), ); } + + // build a list of users except for the current logged in user + Widget _buildUserList() { + return StreamBuilder( + stream: _chatService.getUsersStream(), + builder: (context, snapshot) { + // error + if (snapshot.hasError) { + return const Text("Error"); + } + + //loading + if (snapshot.connectionState == ConnectionState.waiting) { + return const Text("Loading.."); + } + + // return list view + return ListView( + children: snapshot.data! + .map( + (userData) => _buildUserListItem(userData, context)) + .toList(), + ); + }); + } + + // build individual user list item + Widget _buildUserListItem( + Map userData, BuildContext context) { + // display all users except current user + if (userData["email"] != _authService.getCurrentUser()!.email) { + return UserTile( + text: userData["email"], + onTap: () { + // tapped on a user -> go to chat page // TODO + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatPage( + receiverEmail: userData["email"], + receiverID: userData["uid"], + ), + ), + ); + }, + ); + } else { + return Container(); + } + } } diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index dc393f7..83eb96a 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -1,4 +1,4 @@ -import 'package:cofounderella/auth/auth_service.dart'; +import 'package:cofounderella/services/auth/auth_service.dart'; import 'package:flutter/material.dart'; import 'package:cofounderella/components/my_button.dart'; import 'package:cofounderella/components/my_textfield.dart'; @@ -63,7 +63,7 @@ class LoginPage extends StatelessWidget { // email textfield MyTextField( hintText: "E-Mail", - hideText: false, + obscureText: false, controller: _emailController, ), @@ -72,7 +72,7 @@ class LoginPage extends StatelessWidget { // password textfield MyTextField( hintText: "Password", - hideText: true, + obscureText: true, controller: _passwordController, ), @@ -94,7 +94,7 @@ class LoginPage extends StatelessWidget { ), GestureDetector( onTap: onTap, - child: Text( + child: const Text( "Register now", style: TextStyle( fontWeight: FontWeight.bold, diff --git a/lib/pages/register_page.dart b/lib/pages/register_page.dart index 4bfc71a..8d7cf14 100644 --- a/lib/pages/register_page.dart +++ b/lib/pages/register_page.dart @@ -1,4 +1,4 @@ -import 'package:cofounderella/auth/auth_service.dart'; +import 'package:cofounderella/services/auth/auth_service.dart'; import 'package:flutter/material.dart'; import '../components/my_button.dart'; @@ -20,12 +20,12 @@ class RegisterPage extends StatelessWidget { // register method void register(BuildContext context) { // get auth service - final _auth = AuthService(); + final auth = AuthService(); // check if passwords match if (_passwordController.text == _confirmPassController.text) { try { - _auth.signUpWithEmailPassword( + auth.signUpWithEmailPassword( _emailController.text, _passwordController.text, ); @@ -76,19 +76,19 @@ class RegisterPage extends StatelessWidget { // name text field MyTextField( hintText: "First Name", - hideText: false, + obscureText: false, controller: _nameController, ), MyTextField( hintText: "Last Name", - hideText: false, + obscureText: false, controller: _lastnameController, ), // email text field MyTextField( hintText: "E-Mail", - hideText: false, + obscureText: false, controller: _emailController, ), @@ -97,13 +97,13 @@ class RegisterPage extends StatelessWidget { // password text field MyTextField( hintText: "Password", - hideText: true, + obscureText: true, controller: _passwordController, ), MyTextField( hintText: "Confirm Password", - hideText: true, + obscureText: true, controller: _confirmPassController, ), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 8ac7287..60f7392 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,4 +1,7 @@ +import 'package:cofounderella/themes/theme_provider.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -6,9 +9,32 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, appBar: AppBar( title: const Text("Settings"), ), + body: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(25), + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // dark mode switch + const Text("Dark Mode"), + CupertinoSwitch( + value: + Provider.of(context, listen: false).isDarkMode, + onChanged: (value) => + Provider.of(context, listen: false) + .toggleTheme(), + ), + ], + ), + ), ); } } diff --git a/lib/auth/auth_gate.dart b/lib/services/auth/auth_gate.dart similarity index 82% rename from lib/auth/auth_gate.dart rename to lib/services/auth/auth_gate.dart index bd2590b..e8595cd 100644 --- a/lib/auth/auth_gate.dart +++ b/lib/services/auth/auth_gate.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:cofounderella/auth/login_or_register.dart'; +import 'package:cofounderella/services/auth/login_or_register.dart'; import 'package:cofounderella/pages/home_page.dart'; class AuthGate extends StatelessWidget{ @@ -15,13 +15,11 @@ class AuthGate extends StatelessWidget{ // check if user is logged in or not if(snapshot.hasData){ - return const MyHomePage(title: "MyHomePage Test Title",); + return HomePage(); } else { return const LoginOrRegister(); } - - }, ), ); diff --git a/lib/auth/auth_service.dart b/lib/services/auth/auth_service.dart similarity index 55% rename from lib/auth/auth_service.dart rename to lib/services/auth/auth_service.dart index d13cdfe..40bca84 100644 --- a/lib/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -1,8 +1,15 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; class AuthService { - // instance of auth + // instance of auth and firestore final FirebaseAuth _auth = FirebaseAuth.instance; + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + // get current user + User? getCurrentUser() { + return _auth.currentUser; + } //sign in Future signInWithEmailPassword(String email, password) async { @@ -11,6 +18,16 @@ class AuthService { email: email, password: password, ); + + // save user info if it does not already exist + // TODO TESTING - same code snippet as for sign up + _firestore.collection("Users").doc(userCredential.user!.uid).set( + { + 'uid': userCredential.user!.uid, + 'email': email, + }, + ); + return userCredential; } on FirebaseAuthException catch (e) { throw Exception(e.code); @@ -20,11 +37,21 @@ class AuthService { // sign up (register) Future signUpWithEmailPassword(String email, password) async { try { + // create user UserCredential userCredential = await _auth.createUserWithEmailAndPassword( email: email, password: password, ); + + // save user info in a document + _firestore.collection("Users").doc(userCredential.user!.uid).set( + { + 'uid': userCredential.user!.uid, + 'email': email, + }, + ); + return userCredential; } on FirebaseAuthException catch (e) { throw Exception(e.code); diff --git a/lib/auth/login_or_register.dart b/lib/services/auth/login_or_register.dart similarity index 100% rename from lib/auth/login_or_register.dart rename to lib/services/auth/login_or_register.dart diff --git a/lib/services/chat/chat_service.dart b/lib/services/chat/chat_service.dart new file mode 100644 index 0000000..7668ae8 --- /dev/null +++ b/lib/services/chat/chat_service.dart @@ -0,0 +1,67 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cofounderella/models/message.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +class ChatService { + // get instance of firestore and auth + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; + + // get user stream + Stream>> getUsersStream() { + return _firestore.collection("Users").snapshots().map((snapshot) { + return snapshot.docs.map((doc) { + // iterate each user + final user = doc.data(); + + //return user + return user; + }).toList(); + }); + } + + // send message + Future sendMessage(String receiverID, message) async { + // get current user info + final String currentUserID = _auth.currentUser!.uid; + final String currentUserEmail = _auth.currentUser!.email!; + final Timestamp timestamp = Timestamp.now(); + + // create new message + Message newMessage = Message( + senderID: currentUserID, + senderEmail: currentUserEmail, + receiverID: receiverID, + message: message, + timestamp: timestamp, + ); + + // construct chat room ID for the two users (sorted to ensure uniqueness) + List ids = [currentUserID, receiverID]; + ids.sort(); // sort to ensure the chatroomID is the same for any 2 users + String chatRoomID = ids.join('_'); + + // add new message to database + await _firestore + .collection("chat_rooms") + .doc(chatRoomID) + .collection("messages") + .add(newMessage.toMap()); + } + + // get messages + Stream getMessages(String userID, otherUserID) { + // TODO create chat room ID -- same code snippet as above + // construct chat room ID for the two users (sorted to ensure uniqueness) + List ids = [userID, otherUserID]; + ids.sort(); // sort to ensure the chatroomID is the same for any 2 users + String chatRoomID = ids.join('_'); + + return _firestore + .collection("chat_rooms") + .doc(chatRoomID) + .collection("messages") + .orderBy("timestamp", descending: false) + .snapshots(); + } +} diff --git a/lib/themes/theme_provider.dart b/lib/themes/theme_provider.dart new file mode 100644 index 0000000..f540476 --- /dev/null +++ b/lib/themes/theme_provider.dart @@ -0,0 +1,23 @@ +import 'package:cofounderella/themes/dark_mode.dart'; +import 'package:cofounderella/themes/light_mode.dart'; +import 'package:flutter/material.dart'; + +class ThemeProvider extends ChangeNotifier { + ThemeData _themeData = lightMode; + + ThemeData get themeData => _themeData; + + bool get isDarkMode => _themeData == darkMode; + set themeData(ThemeData themeData) { + _themeData = themeData; + notifyListeners(); + } + + void toggleTheme() { + if(_themeData == lightMode) { + themeData = darkMode; + } else { + themeData = lightMode; + } + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index fc36639..6e2efc1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: cupertino_icons: ^1.0.6 firebase_core: ^2.30.1 firebase_auth: ^4.19.4 + cloud_firestore: ^4.17.2 + provider: ^6.1.2 dev_dependencies: flutter_test: