Added Dark Mode (Provider) and simple ChatPage (using Firestore as Database).

master
Rafael 2024-04-30 19:25:16 +02:00
parent 3f3ef66fcf
commit 8cec856e1c
17 changed files with 526 additions and 104 deletions

View File

@ -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<ThemeProvider>(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),
),
);
}
}

View File

@ -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:cofounderella/pages/settings_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View File

@ -2,14 +2,16 @@ import 'package:flutter/material.dart';
class MyTextField extends StatelessWidget { class MyTextField extends StatelessWidget {
final String hintText; final String hintText;
final bool hideText; final bool obscureText;
final TextEditingController controller; final TextEditingController controller;
final FocusNode? focusNode;
const MyTextField({ const MyTextField({
super.key, super.key,
required this.hintText, required this.hintText,
required this.hideText, required this.obscureText,
required this.controller, required this.controller,
this.focusNode,
}); });
@override @override
@ -17,8 +19,9 @@ class MyTextField extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.all(25.0), padding: const EdgeInsets.all(25.0),
child: TextField( child: TextField(
obscureText: hideText, obscureText: obscureText,
controller: controller, controller: controller,
focusNode: focusNode,
decoration: InputDecoration( decoration: InputDecoration(
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderSide: borderSide:

View File

@ -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),
],
),
),
);
}
}

View File

@ -1,10 +1,10 @@
import 'package:cofounderella/auth/auth_gate.dart'; import 'package:cofounderella/services/auth/auth_gate.dart';
import 'package:cofounderella/themes/light_mode.dart'; import 'package:cofounderella/themes/theme_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:provider/provider.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
void main() async { void main() async {
// Firebase stuff // Firebase stuff
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -12,7 +12,12 @@ void main() async {
options: DefaultFirebaseOptions.currentPlatform, options: DefaultFirebaseOptions.currentPlatform,
); );
// Standard stuff // Standard stuff
runApp(const MyApp()); runApp(
ChangeNotifierProvider(
create: (context) => ThemeProvider(),
child: const MyApp(),
),
);
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
@ -24,10 +29,8 @@ class MyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Flutter Demo', // TODO change title title: 'Flutter Demo', // TODO change title
theme: lightMode, theme: Provider.of<ThemeProvider>(context).themeData,
home: const AuthGate(), home: const AuthGate(),
); );
} }
} }

View File

@ -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<String, dynamic> toMap() {
return {
'senderID': senderID,
'senderEmail': senderEmail,
'receiverID': receiverID,
'message': message,
'timestamp': timestamp,
};
}
}

View File

@ -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<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
// 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<String, dynamic> data = doc.data() as Map<String, dynamic>;
// 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
),
)
],
),
);
}
}

View File

@ -1,91 +1,80 @@
import 'package:cofounderella/components/my_drawer.dart'; 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'; import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget { import 'chat_page.dart';
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning class HomePage extends StatelessWidget {
// that it has a State object (defined below) that contains fields that affect HomePage({super.key});
// how it looks.
// This class is the configuration for the state. It holds the values (in this // chat and auth service
// case the title) provided by the parent (in this case the App widget) and final ChatService _chatService = ChatService();
// used by the build method of the State. Fields in a Widget subclass are final AuthService _authService = AuthService();
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
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++;
});
}
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to title: const Text("Home"),
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar // TODO appbar style: remove background and set elevation to 0 ?
// change color while the other colors stay the same. backgroundColor: Colors.transparent,
backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Colors.grey.shade800,
// Here we take the value from the MyHomePage object that was created by elevation: 0,
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
), ),
drawer: const MyDrawer(), drawer: const MyDrawer(),
body: Center( body: _buildUserList(),
// 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: <Widget>[
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.
); );
} }
// 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<Widget>(
(userData) => _buildUserListItem(userData, context))
.toList(),
);
});
}
// build individual user list item
Widget _buildUserListItem(
Map<String, dynamic> 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();
}
}
} }

View File

@ -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:flutter/material.dart';
import 'package:cofounderella/components/my_button.dart'; import 'package:cofounderella/components/my_button.dart';
import 'package:cofounderella/components/my_textfield.dart'; import 'package:cofounderella/components/my_textfield.dart';
@ -63,7 +63,7 @@ class LoginPage extends StatelessWidget {
// email textfield // email textfield
MyTextField( MyTextField(
hintText: "E-Mail", hintText: "E-Mail",
hideText: false, obscureText: false,
controller: _emailController, controller: _emailController,
), ),
@ -72,7 +72,7 @@ class LoginPage extends StatelessWidget {
// password textfield // password textfield
MyTextField( MyTextField(
hintText: "Password", hintText: "Password",
hideText: true, obscureText: true,
controller: _passwordController, controller: _passwordController,
), ),
@ -94,7 +94,7 @@ class LoginPage extends StatelessWidget {
), ),
GestureDetector( GestureDetector(
onTap: onTap, onTap: onTap,
child: Text( child: const Text(
"Register now", "Register now",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@ -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:flutter/material.dart';
import '../components/my_button.dart'; import '../components/my_button.dart';
@ -20,12 +20,12 @@ class RegisterPage extends StatelessWidget {
// register method // register method
void register(BuildContext context) { void register(BuildContext context) {
// get auth service // get auth service
final _auth = AuthService(); final auth = AuthService();
// check if passwords match // check if passwords match
if (_passwordController.text == _confirmPassController.text) { if (_passwordController.text == _confirmPassController.text) {
try { try {
_auth.signUpWithEmailPassword( auth.signUpWithEmailPassword(
_emailController.text, _emailController.text,
_passwordController.text, _passwordController.text,
); );
@ -76,19 +76,19 @@ class RegisterPage extends StatelessWidget {
// name text field // name text field
MyTextField( MyTextField(
hintText: "First Name", hintText: "First Name",
hideText: false, obscureText: false,
controller: _nameController, controller: _nameController,
), ),
MyTextField( MyTextField(
hintText: "Last Name", hintText: "Last Name",
hideText: false, obscureText: false,
controller: _lastnameController, controller: _lastnameController,
), ),
// email text field // email text field
MyTextField( MyTextField(
hintText: "E-Mail", hintText: "E-Mail",
hideText: false, obscureText: false,
controller: _emailController, controller: _emailController,
), ),
@ -97,13 +97,13 @@ class RegisterPage extends StatelessWidget {
// password text field // password text field
MyTextField( MyTextField(
hintText: "Password", hintText: "Password",
hideText: true, obscureText: true,
controller: _passwordController, controller: _passwordController,
), ),
MyTextField( MyTextField(
hintText: "Confirm Password", hintText: "Confirm Password",
hideText: true, obscureText: true,
controller: _confirmPassController, controller: _confirmPassController,
), ),

View File

@ -1,4 +1,7 @@
import 'package:cofounderella/themes/theme_provider.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@ -6,9 +9,32 @@ class SettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
appBar: AppBar( appBar: AppBar(
title: const Text("Settings"), 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<ThemeProvider>(context, listen: false).isDarkMode,
onChanged: (value) =>
Provider.of<ThemeProvider>(context, listen: false)
.toggleTheme(),
),
],
),
),
); );
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.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'; import 'package:cofounderella/pages/home_page.dart';
class AuthGate extends StatelessWidget{ class AuthGate extends StatelessWidget{
@ -15,13 +15,11 @@ class AuthGate extends StatelessWidget{
// check if user is logged in or not // check if user is logged in or not
if(snapshot.hasData){ if(snapshot.hasData){
return const MyHomePage(title: "MyHomePage Test Title",); return HomePage();
} }
else { else {
return const LoginOrRegister(); return const LoginOrRegister();
} }
}, },
), ),
); );

View File

@ -1,8 +1,15 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
class AuthService { class AuthService {
// instance of auth // instance of auth and firestore
final FirebaseAuth _auth = FirebaseAuth.instance; final FirebaseAuth _auth = FirebaseAuth.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
// get current user
User? getCurrentUser() {
return _auth.currentUser;
}
//sign in //sign in
Future<UserCredential> signInWithEmailPassword(String email, password) async { Future<UserCredential> signInWithEmailPassword(String email, password) async {
@ -11,6 +18,16 @@ class AuthService {
email: email, email: email,
password: password, 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; return userCredential;
} on FirebaseAuthException catch (e) { } on FirebaseAuthException catch (e) {
throw Exception(e.code); throw Exception(e.code);
@ -20,11 +37,21 @@ class AuthService {
// sign up (register) // sign up (register)
Future<UserCredential> signUpWithEmailPassword(String email, password) async { Future<UserCredential> signUpWithEmailPassword(String email, password) async {
try { try {
// create user
UserCredential userCredential = UserCredential userCredential =
await _auth.createUserWithEmailAndPassword( await _auth.createUserWithEmailAndPassword(
email: email, email: email,
password: password, password: password,
); );
// save user info in a document
_firestore.collection("Users").doc(userCredential.user!.uid).set(
{
'uid': userCredential.user!.uid,
'email': email,
},
);
return userCredential; return userCredential;
} on FirebaseAuthException catch (e) { } on FirebaseAuthException catch (e) {
throw Exception(e.code); throw Exception(e.code);

View File

@ -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<List<Map<String, dynamic>>> 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<void> 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<String> 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<QuerySnapshot> 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<String> 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();
}
}

View File

@ -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;
}
}
}

View File

@ -37,6 +37,8 @@ dependencies:
cupertino_icons: ^1.0.6 cupertino_icons: ^1.0.6
firebase_core: ^2.30.1 firebase_core: ^2.30.1
firebase_auth: ^4.19.4 firebase_auth: ^4.19.4
cloud_firestore: ^4.17.2
provider: ^6.1.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: