├── test ├── core │ ├── fghg │ └── model │ │ └── stream_converter_test.dart ├── features │ ├── settings │ │ ├── repository │ │ │ └── settings_repository_test.dart │ │ ├── model │ │ │ └── theme_model_test.dart │ │ └── bloc │ │ │ └── theme_bloc_test.dart │ ├── tweeting │ │ ├── bloc │ │ │ └── tweet_media_bloc_test.dart │ │ └── model │ │ │ └── tweet_model_test.dart │ ├── notification │ │ ├── model │ │ │ └── notification_model_test.dart │ │ ├── bloc │ │ │ └── notification_bloc_test.dart │ │ └── repository │ │ │ └── notification_repository_test.dart │ ├── profile │ │ ├── bloc │ │ │ └── image_picker_bloc_test.dart │ │ └── model │ │ │ └── user_profile_model_test.dart │ ├── timeline │ │ ├── bloc │ │ │ ├── timeline_bloc_test.dart │ │ │ └── comment_bloc_test.dart │ │ └── repository │ │ │ └── timeline_repository_test.dart │ └── authentication │ │ └── bloc │ │ └── auth_bloc_test.dart ├── mocks │ └── mocks.dart └── fixtures │ └── fixture_reader.dart ├── android ├── settings_aar.gradle ├── gradle.properties ├── app │ ├── src │ │ ├── main │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── drawable │ │ │ │ │ └── launch_background.xml │ │ │ │ └── values │ │ │ │ │ └── styles.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ └── fc_twitter │ │ │ │ │ └── MainActivity.kt │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ ├── google-services.json │ └── build.gradle ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle └── build.gradle ├── lib ├── features │ ├── messaging │ │ ├── data │ │ │ ├── model │ │ │ │ └── user_model.dart │ │ │ └── repository │ │ │ │ └── firebase_user_repository.dart │ │ ├── domain │ │ │ ├── entity │ │ │ │ └── user_entity.dart │ │ │ └── repository │ │ │ │ └── firebase_user_repository.dart │ │ └── representation │ │ │ ├── widgets │ │ │ └── new_message_icon.dart │ │ │ └── pages │ │ │ └── message_screen.dart │ ├── searching │ │ ├── data │ │ │ ├── model │ │ │ │ └── user_model.dart │ │ │ └── repository │ │ │ │ └── firebase_user_repository.dart │ │ ├── domain │ │ │ ├── entity │ │ │ │ └── user_entity.dart │ │ │ └── repository │ │ │ │ └── firebase_user_repository.dart │ │ └── representation │ │ │ └── pages │ │ │ └── search_screen.dart │ ├── authentication │ │ ├── representation │ │ │ ├── bloc │ │ │ │ ├── bloc.dart │ │ │ │ ├── auth_event.dart │ │ │ │ ├── auth_state.dart │ │ │ │ └── auth_bloc.dart │ │ │ ├── pages │ │ │ │ ├── auth_screen.dart │ │ │ │ └── auth_form.dart │ │ │ └── widgets │ │ │ │ └── login_form.dart │ │ ├── domain │ │ │ ├── user_entity │ │ │ │ └── user_entity.dart │ │ │ └── repository │ │ │ │ └── user_repository.dart │ │ └── data │ │ │ ├── model │ │ │ └── user_model.dart │ │ │ └── repository │ │ │ └── user_repository.dart │ ├── tweeting │ │ ├── representation │ │ │ ├── bloc │ │ │ │ ├── bloc.dart │ │ │ │ ├── tweeting_state.dart │ │ │ │ ├── tweet_media_bloc.dart │ │ │ │ └── tweeting_event.dart │ │ │ └── widgets │ │ │ │ ├── new_tweet_icon.dart │ │ │ │ ├── like_button.dart │ │ │ │ ├── tweet_image_display.dart │ │ │ │ └── media_preview.dart │ │ ├── domain │ │ │ ├── repository │ │ │ │ └── tweeting_repository.dart │ │ │ └── entity │ │ │ │ └── tweet_entity.dart │ │ └── data │ │ │ └── model │ │ │ └── tweet_model.dart │ ├── notification │ │ ├── representation │ │ │ ├── widgets │ │ │ │ ├── mentions.dart │ │ │ │ ├── all_notifications.dart │ │ │ │ ├── notification_icon.dart │ │ │ │ └── notification_item.dart │ │ │ ├── pages │ │ │ │ └── notification_screen.dart │ │ │ └── bloc │ │ │ │ └── notification_bloc.dart │ │ ├── domain │ │ │ ├── entity │ │ │ │ └── notification_entity.dart │ │ │ └── repository │ │ │ │ └── notification_repository.dart │ │ └── data │ │ │ ├── model │ │ │ └── notification_model.dart │ │ │ └── repository │ │ │ └── notification_repository.dart │ ├── settings │ │ ├── domain │ │ │ ├── repository │ │ │ │ └── settings_repository.dart │ │ │ └── entity │ │ │ │ └── theme_entity.dart │ │ ├── data │ │ │ ├── repository │ │ │ │ └── settings_repository.dart │ │ │ └── model │ │ │ │ └── theme_model.dart │ │ └── representation │ │ │ └── bloc │ │ │ └── theme_bloc.dart │ ├── timeline │ │ ├── domain │ │ │ └── repository │ │ │ │ └── timeline_repository.dart.dart │ │ ├── data │ │ │ └── repository │ │ │ │ └── timeline_repository.dart │ │ └── representation │ │ │ ├── bloc │ │ │ ├── comment_bloc.dart │ │ │ └── timeline_bloc.dart │ │ │ ├── widgets │ │ │ └── comment_builder.dart │ │ │ └── pages │ │ │ └── home_screen.dart │ └── profile │ │ ├── domain │ │ ├── repository │ │ │ └── profile_repository.dart.dart │ │ └── entity │ │ │ └── user_profile_entity.dart │ │ ├── representation │ │ ├── widgets │ │ │ ├── avatar.dart │ │ │ ├── user_tab_likes.dart │ │ │ ├── user_tab_media.dart │ │ │ ├── user_tab_replies.dart │ │ │ ├── user_tab_tweets.dart │ │ │ ├── drawer_user_info.dart │ │ │ ├── profile_image.dart │ │ │ └── cover_image.dart │ │ ├── bloc │ │ │ └── image_picker_bloc.dart │ │ └── pages │ │ │ └── profile_screen.dart │ │ └── data │ │ └── model │ │ └── user_profile_model.dart ├── core │ ├── util │ │ ├── themes.dart │ │ └── config.dart │ ├── error │ │ └── failure.dart │ └── model │ │ └── stream_converter.dart └── main.dart ├── ios ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme └── .gitignore ├── .metadata ├── README.md ├── .gitignore └── pubspec.yaml /test/core/fghg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /android/settings_aar.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /lib/features/messaging/data/model/user_model.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/features/messaging/domain/entity/user_entity.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/features/searching/data/model/user_model.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/features/searching/domain/entity/user_entity.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /lib/features/messaging/data/repository/firebase_user_repository.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/features/searching/data/repository/firebase_user_repository.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/features/messaging/domain/repository/firebase_user_repository.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/features/searching/domain/repository/firebase_user_repository.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /lib/features/authentication/representation/bloc/bloc.dart: -------------------------------------------------------------------------------- 1 | export 'auth_bloc.dart'; 2 | export 'auth_event.dart'; 3 | export 'auth_state.dart'; -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.enableR8=true 5 | -------------------------------------------------------------------------------- /lib/features/tweeting/representation/bloc/bloc.dart: -------------------------------------------------------------------------------- 1 | export 'tweeting_bloc.dart'; 2 | export 'tweeting_event.dart'; 3 | export 'tweeting_state.dart'; -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/twitterClone/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/fc_twitter/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.onuifeanyi.fc_twitter 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/features/notification/representation/widgets/mentions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Mentions extends StatelessWidget { 4 | @override 5 | Widget build(BuildContext context) { 6 | return Container( 7 | 8 | ); 9 | } 10 | } -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip 7 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 1aafb3a8b9b0c36241c5f5b34ee914770f015818 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /lib/features/settings/domain/repository/settings_repository.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:dartz/dartz.dart'; 3 | import 'package:fc_twitter/core/error/failure.dart'; 4 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 5 | 6 | abstract class SettingsRepository { 7 | Future> changeTheme(ThemeEntity theme); 8 | } -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/features/notification/domain/entity/notification_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | 4 | class NotificationEntity extends Equatable { 5 | final String userId; 6 | final DocumentReference userProfile; 7 | final DocumentReference tweet; 8 | final bool isSeen; 9 | 10 | NotificationEntity({this.userId ,this.userProfile, this.tweet, this.isSeen}); 11 | @override 12 | List get props => []; 13 | } -------------------------------------------------------------------------------- /lib/features/authentication/domain/user_entity/user_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | 4 | class UserEntity extends Equatable{ 5 | final String name; 6 | final String userName; 7 | final String email; 8 | final String password; 9 | 10 | 11 | UserEntity({this.name, this.userName, @required this.email, @required this.password,}); 12 | 13 | @override 14 | List get props => [name, userName, email, password]; 15 | } -------------------------------------------------------------------------------- /lib/features/settings/domain/entity/theme_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | 4 | class ThemeEntity extends Equatable { 5 | final bool isLight; 6 | final bool isDim; 7 | final bool isLightsOut; 8 | 9 | ThemeEntity({ 10 | @required this.isLight, 11 | @required this.isDim, 12 | @required this.isLightsOut, 13 | }); 14 | 15 | @override 16 | List get props => 17 | [isLight, isDim, isLightsOut]; 18 | } 19 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /lib/features/timeline/domain/repository/timeline_repository.dart.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:dartz/dartz.dart'; 3 | import 'package:fc_twitter/core/error/failure.dart'; 4 | import 'package:fc_twitter/core/model/stream_converter.dart'; 5 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 6 | 7 | abstract class TimeLineRepository { 8 | Future> fetchTweets(); 9 | 10 | Future> fetchComments(TweetEntity tweet); 11 | } -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fc_twitter 2 | 3 | A new Flutter project. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) 13 | 14 | For help getting started with Flutter, view our 15 | [online documentation](https://flutter.dev/docs), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /lib/features/notification/domain/repository/notification_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/error/failure.dart'; 3 | import 'package:fc_twitter/core/model/stream_converter.dart'; 4 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 5 | 6 | abstract class NotificationRepository { 7 | Future> sendLikeNotification(TweetEntity userProfileReference); 8 | 9 | Future> markAllAsSeen(String userId); 10 | 11 | Future> fetchNotifications(String userId); 12 | } -------------------------------------------------------------------------------- /lib/features/authentication/representation/bloc/auth_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 3 | 4 | class AuthEvent extends Equatable { 5 | @override 6 | List get props => []; 7 | } 8 | 9 | class SignUp extends AuthEvent { 10 | final UserEntity user; 11 | 12 | SignUp({this.user}); 13 | 14 | @override 15 | List get props => [user]; 16 | } 17 | 18 | class Login extends AuthEvent { 19 | final UserEntity user; 20 | 21 | Login({this.user}); 22 | 23 | @override 24 | List get props => [user]; 25 | } 26 | 27 | class LogOut extends AuthEvent {} -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | -------------------------------------------------------------------------------- /lib/features/authentication/domain/repository/user_repository.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:dartz/dartz.dart'; 3 | import 'package:fc_twitter/core/error/failure.dart'; 4 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 5 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 6 | import 'package:firebase_auth/firebase_auth.dart'; 7 | 8 | abstract class UserRepository { 9 | Future> signUpNewUser(UserEntity user); 10 | 11 | Future> logInUser(UserEntity user); 12 | 13 | Future> saveUserDetail(UserProfileEntity userProfileModel); 14 | 15 | Future> logOutUser(); 16 | } -------------------------------------------------------------------------------- /lib/features/authentication/data/model/user_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | 4 | class UserModel extends UserEntity { 5 | UserModel({ 6 | name, 7 | userName, 8 | @required email, 9 | @required password, 10 | }) : super( 11 | name: name, 12 | userName: userName, 13 | email: email, 14 | password: password, 15 | ); 16 | 17 | factory UserModel.fromEntity(UserEntity userEntity) { 18 | return UserModel( 19 | email: userEntity.email, 20 | password: userEntity.password, 21 | userName: userEntity.userName, 22 | name: userEntity.name, 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.50' 3 | repositories { 4 | google() 5 | jcenter() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:3.5.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | classpath 'com.google.gms:google-services:4.3.4' 12 | } 13 | } 14 | 15 | allprojects { 16 | repositories { 17 | google() 18 | jcenter() 19 | } 20 | } 21 | 22 | rootProject.buildDir = '../build' 23 | subprojects { 24 | project.buildDir = "${rootProject.buildDir}/${project.name}" 25 | } 26 | subprojects { 27 | project.evaluationDependsOn(':app') 28 | } 29 | 30 | task clean(type: Delete) { 31 | delete rootProject.buildDir 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | /lib/secrets.dart 34 | 35 | # Web related 36 | lib/generated_plugin_registrant.dart 37 | 38 | # Symbolication related 39 | app.*.symbols 40 | 41 | # Obfuscation related 42 | app.*.map.json 43 | -------------------------------------------------------------------------------- /test/features/settings/repository/settings_repository_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:mockito/mockito.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | 5 | class MockSharedPreference extends Mock implements SharedPreferences {} 6 | 7 | void main() { 8 | // ThemeEntity themeEntity; 9 | // MockSharedPreference sharedPreference; 10 | // SettingsRepositoryImpl settingsRepositoryImpl; 11 | 12 | // setUp(() { 13 | // themeEntity = ThemeEntity( 14 | // isLight: true, 15 | // isDim: false, 16 | // isLightsOut: true, 17 | // ); 18 | // sharedPreference = MockSharedPreference(); 19 | // settingsRepositoryImpl = 20 | // SettingsRepositoryImpl(sharedPreferences: sharedPreference); 21 | // }); 22 | 23 | group('settings repository', () {}); 24 | } 25 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/features/messaging/representation/widgets/new_message_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class NewMessageIcon extends StatelessWidget { 4 | @override 5 | Widget build(BuildContext context) { 6 | return Stack( 7 | children: [ 8 | FloatingActionButton( 9 | backgroundColor: Theme.of(context).primaryColor, 10 | child: Padding( 11 | padding: const EdgeInsets.only(top: 3), 12 | child: Icon(Icons.mail_outline_rounded), 13 | ), 14 | onPressed: () {}, 15 | ), 16 | Positioned( 17 | right: 14, 18 | top: 13, 19 | child: CircleAvatar( 20 | maxRadius: 6, 21 | backgroundColor: Theme.of(context).primaryColor, 22 | child: Icon( 23 | Icons.add, 24 | size: 15, 25 | color: Colors.white, 26 | ), 27 | ), 28 | ), 29 | ], 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/features/settings/data/repository/settings_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:fc_twitter/core/error/failure.dart'; 5 | import 'package:fc_twitter/features/settings/data/model/theme_model.dart'; 6 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 7 | import 'package:fc_twitter/features/settings/domain/repository/settings_repository.dart'; 8 | import 'package:shared_preferences/shared_preferences.dart'; 9 | 10 | class SettingsRepositoryImpl implements SettingsRepository { 11 | final SharedPreferences sharedPreferences; 12 | 13 | SettingsRepositoryImpl({this.sharedPreferences}); 14 | 15 | @override 16 | Future> changeTheme( 17 | ThemeEntity theme) async { 18 | try { 19 | final themeMap = json.encode(ThemeModel.fromEntity(theme).toJson()); 20 | await sharedPreferences.setString('theme', themeMap); 21 | return Right(theme); 22 | } catch (_) { 23 | return Left(SettingsFailure()); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/features/messaging/representation/pages/message_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_icons/flutter_icons.dart'; 3 | 4 | class MessageScreen extends StatelessWidget { 5 | @override 6 | Widget build(BuildContext context) { 7 | return Scaffold( 8 | appBar: AppBar( 9 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 10 | elevation: 1, 11 | leading: IconButton( 12 | onPressed: () => Scaffold.of(context).openDrawer(), 13 | icon: Icon(Foundation.list, color: Theme.of(context).primaryColor), 14 | ), 15 | title: Text('Messages', style: Theme.of(context).textTheme.headline6), 16 | actions: [ 17 | IconButton( 18 | icon: Icon( 19 | AntDesign.setting, 20 | color: Theme.of(context).primaryColor, 21 | ), 22 | onPressed: () {}), 23 | ], 24 | ), 25 | body: ListView( 26 | children: [ 27 | SizedBox( 28 | height: 1000, 29 | ) 30 | ], 31 | ), 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /android/app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "415201169644", 4 | "project_id": "fc-twitter", 5 | "storage_bucket": "fc-twitter.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:415201169644:android:bedc42e5cbf159a8c815c9", 11 | "android_client_info": { 12 | "package_name": "com.onuifeanyi.fc_twitter" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "415201169644-v2aoqqt4ioquik1ubllu32lnktqjdhlv.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyD4emT9F3Llu_84UJRlFL8VeAaEZlJa6O0" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "415201169644-v2aoqqt4ioquik1ubllu32lnktqjdhlv.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | ], 38 | "configuration_version": "1" 39 | } -------------------------------------------------------------------------------- /lib/features/tweeting/representation/bloc/tweeting_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class TweetingState extends Equatable { 5 | @override 6 | List get props => []; 7 | 8 | void showSnackBar( 9 | BuildContext context, 10 | GlobalKey scaffoldKey, 11 | String message, 12 | int time, { 13 | bool isError = false, 14 | }) { 15 | scaffoldKey.currentState.removeCurrentSnackBar(); 16 | scaffoldKey.currentState.showSnackBar( 17 | SnackBar( 18 | content: Text( 19 | message, 20 | style: TextStyle(color: Colors.white), 21 | ), 22 | duration: Duration(seconds: time), 23 | backgroundColor: isError 24 | ? Theme.of(context).errorColor 25 | : Theme.of(context).primaryColor, 26 | ), 27 | ); 28 | } 29 | } 30 | 31 | class InitialTweetingState extends TweetingState {} 32 | 33 | class TweetingError extends TweetingState { 34 | final String message; 35 | 36 | TweetingError({this.message}); 37 | 38 | @override 39 | List get props => [message]; 40 | } 41 | 42 | class TweetingComplete extends TweetingState {} 43 | -------------------------------------------------------------------------------- /lib/features/authentication/representation/bloc/auth_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class AuthState extends Equatable { 5 | final bool isLoading; 6 | 7 | AuthState({this.isLoading = false}); 8 | void showSnackBar(BuildContext context, GlobalKey scaffoldKey, 9 | String message, 10 | {bool isError = false}) { 11 | scaffoldKey.currentState.removeCurrentSnackBar(); 12 | scaffoldKey.currentState.showSnackBar( 13 | SnackBar( 14 | content: Text( 15 | message, 16 | style: TextStyle(color: Colors.white), 17 | ), 18 | backgroundColor: isError 19 | ? Theme.of(context).errorColor 20 | : Theme.of(context).primaryColor, 21 | ), 22 | ); 23 | } 24 | 25 | @override 26 | List get props => []; 27 | } 28 | 29 | class InitialAuthState extends AuthState {} 30 | 31 | class AuthInProgress extends AuthState { 32 | AuthInProgress() : super(isLoading: true); 33 | } 34 | 35 | class AuthComplete extends AuthState {} 36 | 37 | class AuthFailed extends AuthState { 38 | final String message; 39 | 40 | AuthFailed({this.message}); 41 | @override 42 | List get props => [message]; 43 | } -------------------------------------------------------------------------------- /lib/features/tweeting/representation/widgets/new_tweet_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/tweeting/representation/bloc/tweet_media_bloc.dart'; 2 | import 'package:fc_twitter/features/tweeting/representation/pages/tweet_screen.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | import 'package:flutter_icons/flutter_icons.dart'; 6 | 7 | class NewTweetIcon extends StatelessWidget { 8 | @override 9 | Widget build(BuildContext context) { 10 | return Stack( 11 | children: [ 12 | FloatingActionButton( 13 | backgroundColor: Theme.of(context).primaryColor, 14 | child: Padding( 15 | padding: const EdgeInsets.only(left: 8), 16 | child: Icon(MaterialCommunityIcons.feather), 17 | ), 18 | onPressed: () { 19 | context.read().add(Reset()); 20 | Navigator.pushNamed(context, TweetScreen.pageId); 21 | } 22 | , 23 | ), 24 | Positioned( 25 | left: 14, 26 | top: 15, 27 | child: Icon( 28 | Icons.add, 29 | size: 15, 30 | color: Colors.white, 31 | ), 32 | ), 33 | ], 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/core/util/themes.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | enum ThemeOptions { 4 | Light, 5 | Dark, 6 | } 7 | 8 | enum DarkThemeOptions { 9 | Dim, 10 | LightsOut, 11 | } 12 | 13 | final themeOptions = { 14 | ThemeOptions.Light: ThemeData( 15 | brightness: Brightness.light, 16 | primarySwatch: Colors.blue, 17 | primaryColor: Colors.blue, 18 | accentColor: Colors.black54, 19 | backgroundColor: Color(0xFFE6ECF0), 20 | scaffoldBackgroundColor: Colors.white, 21 | visualDensity: VisualDensity.adaptivePlatformDensity, 22 | ), 23 | DarkThemeOptions.Dim: ThemeData( 24 | brightness: Brightness.dark, 25 | primarySwatch: Colors.blue, 26 | primaryColor: Colors.blue, 27 | accentColor: Color(0xFF8899A6), 28 | backgroundColor: Color(0xFF10171E), 29 | scaffoldBackgroundColor: Color(0xFF15202B), 30 | visualDensity: VisualDensity.adaptivePlatformDensity, 31 | ), 32 | DarkThemeOptions.LightsOut: ThemeData( 33 | brightness: Brightness.dark, 34 | primaryColor: Colors.blue, 35 | primarySwatch: Colors.blue, 36 | accentColor: Color(0xFF7A8087), 37 | backgroundColor: Color(0xFF202327), 38 | scaffoldBackgroundColor: Colors.black, 39 | visualDensity: VisualDensity.adaptivePlatformDensity, 40 | ), 41 | }; -------------------------------------------------------------------------------- /lib/features/settings/data/model/theme_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | 4 | class ThemeModel extends ThemeEntity { 5 | ThemeModel({ 6 | @required isLight, 7 | @required isDim, 8 | @required isLightsOut, 9 | }) : super( 10 | isLight: isLight, 11 | isDim: isDim, 12 | isLightsOut: isLightsOut, 13 | ); 14 | 15 | factory ThemeModel.fromJson(Map json) { 16 | return ThemeModel( 17 | isLight: json['isLight'], 18 | isDim: json['isDim'], 19 | isLightsOut: json['isLightsOut'], 20 | ); 21 | } 22 | 23 | factory ThemeModel.fromEntity(ThemeEntity theme) { 24 | return ThemeModel( 25 | isLight: theme.isLight, 26 | isDim: theme.isDim, 27 | isLightsOut: theme.isLightsOut, 28 | ); 29 | } 30 | 31 | ThemeEntity toEntity() { 32 | return ThemeEntity( 33 | isLight: this.isLight, 34 | isDim: this.isDim, 35 | isLightsOut: this.isLightsOut, 36 | ); 37 | } 38 | 39 | Map toJson() { 40 | return { 41 | 'isLight': this.isLight, 42 | 'isDim': this.isDim, 43 | 'isLightsOut': this.isLightsOut, 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: fc_twitter 2 | description: A new Flutter project. 3 | 4 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 5 | 6 | version: 1.0.0+1 7 | 8 | environment: 9 | sdk: ">=2.7.0 <3.0.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | cached_network_image: ^2.4.1 15 | cloud_firestore: ^0.14.3+1 16 | cupertino_icons: ^1.0.0 17 | dartz: ^0.9.2 18 | equatable: ^1.2.5 19 | firebase_auth: ^0.18.3+1 20 | firebase_core: ^0.5.2+1 21 | firebase_messaging: ^7.0.3 22 | firebase_storage: ^5.1.0 23 | flutter_absolute_path: ^1.0.6 24 | flutter_bloc: ^6.1.1 25 | flutter_icons: ^1.1.0 26 | get_it: ^5.0.3 27 | http: ^0.12.2 28 | image_picker: ^0.6.7+14 29 | multi_image_picker: ^4.7.14 30 | shared_preferences: ^0.5.12+4 31 | 32 | dev_dependencies: 33 | flutter_test: 34 | sdk: flutter 35 | mockito: ^4.1.3 36 | 37 | flutter: 38 | uses-material-design: true 39 | # To add assets to your application, add an assets section, like this: 40 | # assets: 41 | # - images/a_dot_burr.jpeg 42 | # - images/a_dot_ham.jpeg 43 | # fonts: 44 | # - family: Schyler 45 | # fonts: 46 | # - asset: fonts/Schyler-Regular.ttf 47 | # - asset: fonts/Schyler-Italic.ttf 48 | # style: italic 49 | -------------------------------------------------------------------------------- /test/features/settings/model/theme_model_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/settings/data/model/theme_model.dart'; 2 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | import '../../../fixtures/fixture_reader.dart'; 6 | 7 | void main() { 8 | ThemeModel themeModel; 9 | ThemeEntity themeEntity; 10 | Map json; 11 | 12 | setUp(() { 13 | themeModel = themeModelFixture(); 14 | themeEntity = themeEntityFixture(); 15 | json = themeJsonFixture(); 16 | }); 17 | 18 | group('theme model', () { 19 | test('should return a valid model from json file', () { 20 | 21 | final result = ThemeModel.fromJson(json); 22 | 23 | expect(result, themeModel); 24 | }); 25 | 26 | test('should return a valid model from entity', () { 27 | final result = ThemeModel.fromEntity(themeEntity); 28 | 29 | expect(result, themeModel); 30 | }); 31 | 32 | test('should return a valid entity from model', () { 33 | final result = themeModel.toEntity(); 34 | 35 | expect(result, themeEntity); 36 | }); 37 | 38 | test('should return a valid json from model', () { 39 | final result = themeModel.toJson(); 40 | 41 | expect(result, json); 42 | }); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /lib/core/error/failure.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | abstract class Failure extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | // General failures 9 | class AuthFailure extends Failure { 10 | final String message; 11 | 12 | AuthFailure({this.message}); 13 | 14 | @override 15 | List get props => [message]; 16 | } 17 | 18 | class TimeLineFailure extends Failure { 19 | final String message; 20 | 21 | TimeLineFailure({this.message}); 22 | 23 | @override 24 | List get props => [message]; 25 | } 26 | 27 | class NotificationFailure extends Failure { 28 | final String message; 29 | 30 | NotificationFailure({this.message}); 31 | 32 | @override 33 | List get props => [message]; 34 | } 35 | 36 | class TweetingFailure extends Failure { 37 | final String message; 38 | 39 | TweetingFailure({this.message}); 40 | 41 | @override 42 | List get props => [message]; 43 | } 44 | 45 | class ProfileFailure extends Failure { 46 | final String message; 47 | 48 | ProfileFailure({this.message}); 49 | 50 | @override 51 | List get props => [message]; 52 | } 53 | 54 | class SettingsFailure extends Failure { 55 | final String message; 56 | 57 | SettingsFailure({this.message}); 58 | 59 | @override 60 | List get props => [message]; 61 | } -------------------------------------------------------------------------------- /lib/features/profile/domain/repository/profile_repository.dart.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'dart:io'; 3 | 4 | import 'package:dartz/dartz.dart'; 5 | import 'package:fc_twitter/core/error/failure.dart'; 6 | import 'package:fc_twitter/core/model/stream_converter.dart'; 7 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 8 | import 'package:image_picker/image_picker.dart'; 9 | 10 | abstract class ProfileRepository { 11 | Future> getUserProfile(String userId); 12 | 13 | Future> pickImage(ImageSource source, bool isCoverPhoto); 14 | 15 | Future> uploadImage(UserProfileEntity userProfile); 16 | 17 | Future> updateUserProfile(UserProfileEntity userProfile); 18 | 19 | Future> follow(UserProfileEntity userProfile ,UserProfileEntity currentUser); 20 | 21 | Future> unfollow(UserProfileEntity userProfile ,UserProfileEntity currentUser); 22 | 23 | Future> fetchUserTweets(String userId); 24 | 25 | Future> fetchUserReplies(String userId); 26 | 27 | Future> fetchUserMedias(String userId); 28 | 29 | Future> fetchUserLikes(String userId); 30 | } -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/avatar.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 3 | import 'package:fc_twitter/features/profile/representation/pages/profile_screen.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | class Avatar extends StatelessWidget { 7 | const Avatar({ 8 | Key key, 9 | @required UserProfileEntity userProfile, 10 | @required double radius, 11 | }) : userProfile = userProfile, 12 | _radius = radius, 13 | super(key: key); 14 | 15 | final UserProfileEntity userProfile; 16 | final double _radius; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final theme = Theme.of(context); 21 | return GestureDetector( 22 | onTap: () => Navigator.pushNamed(context, ProfileScreen.pageId, arguments: userProfile), 23 | child: CachedNetworkImage( 24 | imageUrl: userProfile.profilePhoto, 25 | imageBuilder: (_, imageProvider) => CircleAvatar( 26 | radius: _radius, 27 | backgroundColor: theme.accentColor, 28 | backgroundImage: imageProvider, 29 | ), 30 | placeholder: (_, __) => CircleAvatar( 31 | radius: _radius, 32 | backgroundColor: theme.accentColor, 33 | child: Icon(Icons.person, size: _radius * 2), 34 | ), 35 | fit: BoxFit.contain, 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/features/notification/representation/widgets/all_notifications.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 2 | import 'package:fc_twitter/features/notification/representation/bloc/notification_bloc.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | 6 | import 'notification_item.dart'; 7 | 8 | class AllNotifications extends StatelessWidget { 9 | @override 10 | Widget build(BuildContext context) { 11 | return BlocBuilder( 12 | // buildWhen: (_, currentState) { 13 | // return currentState is FetchingNotificationsComplete; 14 | // }, 15 | builder: (context, state) { 16 | if (state is FetchingNotificationsComplete) { 17 | return StreamBuilder>( 18 | stream: state.notificationStream, 19 | builder: (context, snapshot) { 20 | return snapshot.hasData 21 | ? ListView.builder( 22 | itemCount: snapshot.data.length, 23 | itemBuilder: (ctx, index) => NotificationItem( 24 | notification: snapshot.data[index], 25 | ), 26 | ) 27 | : Center(child: Text('nothing')); 28 | }, 29 | ); 30 | } 31 | return Center(child: CircularProgressIndicator()); 32 | }, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/core/model/stream_converter.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:fc_twitter/features/notification/data/model/notification_model.dart'; 4 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 5 | import 'package:fc_twitter/features/tweeting/data/model/tweet_model.dart'; 6 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 7 | 8 | class StreamConverter extends Equatable { 9 | final CollectionReference collection; 10 | final Query query; 11 | 12 | StreamConverter({this.collection, this.query}); 13 | 14 | Stream> fromCollectionToTweets(CollectionReference tweets) { 15 | return tweets.snapshots().map((snapshot) { 16 | return snapshot.docs 17 | .map((doc) => TweetModel.fromSnapShot(doc)) 18 | .toList(); 19 | }); 20 | } 21 | 22 | Stream> fromQueryToTweets(Query query) { 23 | return query.snapshots().map((snapshot) { 24 | return snapshot.docs 25 | .map((doc) => TweetModel.fromSnapShot(doc)) 26 | .toList(); 27 | }); 28 | } 29 | 30 | Stream> fromQueryToNotification(Query query) { 31 | return query.snapshots().map((snapshot) { 32 | return snapshot.docs 33 | .map((doc) => NotificationModel.fromDoc(doc)) 34 | .toList(); 35 | }); 36 | } 37 | 38 | @override 39 | List get props => []; 40 | } 41 | -------------------------------------------------------------------------------- /lib/features/tweeting/domain/repository/tweeting_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/error/failure.dart'; 3 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 4 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 5 | import 'package:multi_image_picker/multi_image_picker.dart'; 6 | 7 | abstract class TweetingRepository { 8 | Future> comment( 9 | {UserProfileEntity userProfile, 10 | TweetEntity tweet, 11 | TweetEntity commentTweet}); 12 | 13 | Future> quoteTweet( 14 | {UserProfileEntity userProfile, 15 | TweetEntity tweet, 16 | TweetEntity quoteTweet}); 17 | 18 | Future> sendTweet( 19 | UserProfileEntity userProfile, TweetEntity tweet); 20 | 21 | Future> likeTweet( 22 | UserProfileEntity userProfile, TweetEntity tweet); 23 | 24 | Future> unlikeTweet( 25 | UserProfileEntity userProfile, TweetEntity tweet); 26 | 27 | Future> retweet( 28 | UserProfileEntity userProfile, TweetEntity tweet); 29 | 30 | Future> undoRetweet( 31 | UserProfileEntity userProfile, TweetEntity tweet); 32 | 33 | Future>> pickImages(); 34 | 35 | Future>> uploadImages( 36 | List images); 37 | } 38 | -------------------------------------------------------------------------------- /lib/features/searching/representation/pages/search_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_icons/flutter_icons.dart'; 3 | 4 | class SearchScreen extends StatelessWidget { 5 | @override 6 | Widget build(BuildContext context) { 7 | return Scaffold( 8 | appBar: AppBar( 9 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 10 | elevation: 1, 11 | leading: IconButton( 12 | onPressed: () => Scaffold.of(context).openDrawer(), 13 | icon: Icon(Foundation.list, color: Theme.of(context).primaryColor), 14 | ), 15 | title: Container( 16 | padding: const EdgeInsets.only(left: 15), 17 | height: 40, 18 | decoration: BoxDecoration( 19 | color: Theme.of(context).backgroundColor, 20 | borderRadius: BorderRadius.circular(30), 21 | ), 22 | child: TextField( 23 | decoration: InputDecoration( 24 | hintText: 'Search Twitter', 25 | border: InputBorder.none, 26 | ), 27 | ), 28 | ), 29 | actions: [ 30 | IconButton( 31 | icon: Icon( 32 | AntDesign.setting, 33 | color: Theme.of(context).primaryColor, 34 | ), 35 | onPressed: () {}), 36 | ], 37 | ), 38 | body: ListView( 39 | children: [ 40 | SizedBox( 41 | height: 1000, 42 | ), 43 | ], 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/features/notification/data/model/notification_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class NotificationModel extends NotificationEntity { 6 | NotificationModel({ 7 | @required userProfile, 8 | @required tweet, 9 | userId, 10 | isSeen, 11 | }) : super( 12 | userId: userId, 13 | userProfile: userProfile, 14 | tweet: tweet, 15 | isSeen: isSeen ?? false, 16 | ); 17 | 18 | factory NotificationModel.fromDoc(DocumentSnapshot doc) { 19 | final data = doc.data(); 20 | return NotificationModel( 21 | userId: data['userId'], 22 | userProfile: data['userProfile'], 23 | tweet: data['tweet'], 24 | isSeen: data['isSeen'], 25 | ); 26 | } 27 | 28 | factory NotificationModel.fromEntity(NotificationEntity notification) { 29 | return NotificationModel( 30 | userId: notification.userId, 31 | userProfile: notification.userProfile, 32 | tweet: notification.tweet, 33 | isSeen: notification.isSeen, 34 | ); 35 | } 36 | 37 | NotificationEntity toEntity() { 38 | return NotificationEntity( 39 | userId: this.userId, 40 | userProfile: this.userProfile, 41 | tweet: this.tweet, 42 | isSeen: this.isSeen, 43 | ); 44 | } 45 | 46 | Map toMap() { 47 | return { 48 | 'userId': this.userId, 49 | 'userProfile': this.userProfile, 50 | 'tweet': this.tweet, 51 | 'isSeen': this.isSeen, 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/core/util/config.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class Config { 4 | static double yMargin(BuildContext context, double height) { 5 | var isPotrait = MediaQuery.of(context).orientation == Orientation.portrait; 6 | double viewPortHeight = isPotrait 7 | ? MediaQuery.of(context).size.height 8 | : MediaQuery.of(context).size.width; 9 | viewPortHeight = viewPortHeight > 950 ? 950 : viewPortHeight; 10 | return height * (viewPortHeight / 100); 11 | } 12 | 13 | static double xMargin(BuildContext context, double width) { 14 | var isPotrait = MediaQuery.of(context).orientation == Orientation.portrait; 15 | double viewPortwidth = isPotrait 16 | ? MediaQuery.of(context).size.width 17 | : MediaQuery.of(context).size.height; 18 | viewPortwidth = viewPortwidth > 650 ? 650 : viewPortwidth; 19 | return width * (viewPortwidth / 100); 20 | } 21 | 22 | static double defaultSize(BuildContext context, double size) { 23 | var isPotrait = MediaQuery.of(context).orientation == Orientation.portrait; 24 | double viewPortwidth = isPotrait 25 | ? MediaQuery.of(context).size.width 26 | : MediaQuery.of(context).size.width; 27 | return size * (viewPortwidth / 100); 28 | } 29 | 30 | static double textSize(BuildContext context, double size) { 31 | var isPotrait = MediaQuery.of(context).orientation == Orientation.portrait; 32 | double viewPortwidth = isPotrait 33 | ? MediaQuery.of(context).size.width 34 | : MediaQuery.of(context).size.height; 35 | viewPortwidth = viewPortwidth > 500 ? 500 : viewPortwidth; 36 | return size * (viewPortwidth / 100); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | fc_twitter 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /lib/features/timeline/data/repository/timeline_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:dartz/dartz.dart'; 3 | import 'package:fc_twitter/core/error/failure.dart'; 4 | import 'package:fc_twitter/core/model/stream_converter.dart'; 5 | import 'package:fc_twitter/features/timeline/domain/repository/timeline_repository.dart.dart'; 6 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 7 | 8 | class TimeLineRepositoryImpl implements TimeLineRepository { 9 | final FirebaseFirestore firebaseFirestore; 10 | 11 | TimeLineRepositoryImpl({this.firebaseFirestore}) 12 | : assert(firebaseFirestore != null); 13 | 14 | @override 15 | Future> fetchTweets() async { 16 | try { 17 | final collection = firebaseFirestore.collection('tweets'); 18 | return Right(StreamConverter(collection: collection)); 19 | } catch (error) { 20 | print(error); 21 | return Left(TimeLineFailure(message: 'Failed to load tweets')); 22 | } 23 | } 24 | 25 | @override 26 | Future> fetchComments( 27 | TweetEntity tweet) async { 28 | try { 29 | final tweetReference = firebaseFirestore 30 | .collection('tweets') 31 | .doc(tweet.isRetweet ? tweet.retweetTo.id : tweet.id); 32 | final collection = firebaseFirestore 33 | .collection('tweets') 34 | .where('commentTo', isEqualTo: tweetReference).where('isRetweet', isEqualTo: false); 35 | return Right(StreamConverter(query: collection)); 36 | } catch (error) { 37 | print(error); 38 | return Left(TimeLineFailure(message: 'Failed to load comments')); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/features/tweeting/representation/bloc/tweet_media_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:fc_twitter/features/tweeting/domain/repository/tweeting_repository.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:multi_image_picker/multi_image_picker.dart'; 5 | 6 | class TweetMediaEvent extends Equatable { 7 | @override 8 | List get props => []; 9 | } 10 | 11 | class Reset extends TweetMediaEvent {} 12 | 13 | class PickMultiImages extends TweetMediaEvent {} 14 | 15 | class TweetMediaState extends Equatable { 16 | @override 17 | List get props => []; 18 | } 19 | 20 | class InitialMediaState extends TweetMediaState {} 21 | 22 | class MultiImagesLoaded extends TweetMediaState { 23 | final List images; 24 | 25 | MultiImagesLoaded({this.images}); 26 | } 27 | 28 | class TweetMediaBloc extends Bloc { 29 | final TweetingRepository tweetingRepository; 30 | TweetMediaBloc({TweetMediaState initialState, this.tweetingRepository}) 31 | : super(initialState); 32 | 33 | @override 34 | Stream mapEventToState(TweetMediaEvent event) async* { 35 | if (event is Reset) { 36 | yield InitialMediaState(); 37 | } 38 | if (event is PickMultiImages) { 39 | yield* _mapPickMultiImagesToState(); 40 | } 41 | } 42 | 43 | Stream _mapPickMultiImagesToState() async* { 44 | final imagesEither = await tweetingRepository.pickImages(); 45 | yield* imagesEither.fold((failure) async* { 46 | yield InitialMediaState(); 47 | print(failure.message); 48 | }, (images) async* { 49 | yield InitialMediaState(); 50 | yield MultiImagesLoaded(images: images); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/features/tweeting/bloc/tweet_media_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/error/failure.dart'; 3 | import 'package:fc_twitter/features/tweeting/representation/bloc/tweet_media_bloc.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:mockito/mockito.dart'; 6 | 7 | import '../../../mocks/mocks.dart'; 8 | 9 | void main() { 10 | MockTweetingRepository mockTweetingRepository; 11 | TweetMediaBloc tweetMediaBloc; 12 | 13 | setUp(() { 14 | mockTweetingRepository = MockTweetingRepository(); 15 | tweetMediaBloc = TweetMediaBloc( 16 | initialState: InitialMediaState(), 17 | tweetingRepository: mockTweetingRepository, 18 | ); 19 | }); 20 | 21 | test(('confirm inistial bloc state'), () { 22 | expect(tweetMediaBloc.state, equals(InitialMediaState())); 23 | }); 24 | 25 | group('tweetMedia bloc PickMultiImages Event', () { 26 | test('should emit [InitialMediaState, MultiImagesLoaded] when successful', 27 | () async { 28 | when(mockTweetingRepository.pickImages()).thenAnswer( 29 | (_) => Future.value(Right([])), 30 | ); 31 | 32 | final expectations = [ 33 | InitialMediaState(), 34 | MultiImagesLoaded(), 35 | ]; 36 | expectLater(tweetMediaBloc, emitsInOrder(expectations)); 37 | 38 | tweetMediaBloc.add(PickMultiImages()); 39 | }); 40 | 41 | test('should emit [InitialMediaState] when it fails', 42 | () async { 43 | when(mockTweetingRepository.pickImages()).thenAnswer( 44 | (_) => Future.value(Left(TweetingFailure())), 45 | ); 46 | 47 | final expectations = [ 48 | InitialMediaState(), 49 | ]; 50 | expectLater(tweetMediaBloc, emitsInOrder(expectations)); 51 | 52 | tweetMediaBloc.add(PickMultiImages()); 53 | }); 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /lib/features/settings/representation/bloc/theme_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:fc_twitter/core/util/themes.dart'; 3 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 4 | import 'package:fc_twitter/features/settings/domain/repository/settings_repository.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | 8 | class ThemeEvent extends Equatable { 9 | @override 10 | List get props => []; 11 | 12 | } 13 | 14 | class ChangeTheme extends ThemeEvent { 15 | final ThemeEntity theme; 16 | 17 | ChangeTheme(this.theme); 18 | 19 | @override 20 | List get props => [theme]; 21 | } 22 | class ThemeState extends Equatable { 23 | final ThemeData theme; 24 | 25 | ThemeState({this.theme}); 26 | @override 27 | List get props => [theme]; 28 | } 29 | 30 | class AppTheme extends ThemeState { 31 | final ThemeData newTheme; 32 | 33 | AppTheme(this.newTheme) : super(theme: newTheme); 34 | } 35 | 36 | class ThemeBloc extends Bloc { 37 | final SettingsRepository settingsRepository; 38 | ThemeBloc({ 39 | @required AppTheme appTheme, 40 | this.settingsRepository, 41 | }) : super(appTheme); 42 | 43 | @override 44 | Stream mapEventToState(ThemeEvent event) async* { 45 | if (event is ChangeTheme) { 46 | final changeEither = await settingsRepository.changeTheme(event.theme); 47 | yield* changeEither.fold((falure) => throw UnimplementedError(), 48 | (theme) async* { 49 | if (theme.isLight) { 50 | yield AppTheme(themeOptions[ThemeOptions.Light]); 51 | } else if (theme.isDim) { 52 | yield AppTheme(themeOptions[DarkThemeOptions.Dim]); 53 | } else 54 | yield AppTheme(themeOptions[DarkThemeOptions.LightsOut]); 55 | }); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/features/profile/domain/entity/user_profile_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:flutter/rendering.dart'; 4 | 5 | class UserProfileEntity extends Equatable { 6 | final String id; 7 | final String token; 8 | final String name; 9 | final String userName; 10 | final String bio; 11 | final String location; 12 | final String website; 13 | final String dateOfBirth; 14 | final String dateJoined; 15 | final dynamic profilePhoto; 16 | final dynamic coverPhoto; 17 | final List following; 18 | final List followers; 19 | 20 | UserProfileEntity({ 21 | this.id, 22 | this.token, 23 | this.name, 24 | this.userName, 25 | this.bio, 26 | this.location, 27 | this.website, 28 | this.dateOfBirth, 29 | this.dateJoined, 30 | this.profilePhoto, 31 | this.coverPhoto, 32 | this.following, 33 | this.followers, 34 | }); 35 | @SemanticsHintOverrides() 36 | List get props => [id, name, userName]; 37 | 38 | UserProfileEntity copyWith({ 39 | String name, 40 | String token, 41 | String bio, 42 | String location, 43 | String website, 44 | dynamic profilePhoto, 45 | dynamic coverPhoto, 46 | List following, 47 | List followers, 48 | }) { 49 | return UserProfileEntity( 50 | id: this.id, 51 | token: token ?? this.token, 52 | userName: this.userName, 53 | name: name ?? this.name, 54 | bio: bio ?? this.bio, 55 | location: location ?? this.location, 56 | website: website ?? this.website, 57 | profilePhoto: profilePhoto ?? this.profilePhoto, 58 | coverPhoto: coverPhoto ?? this.coverPhoto, 59 | dateOfBirth: this.dateOfBirth, 60 | dateJoined: this.dateJoined, 61 | following: following ?? this.following, 62 | followers: followers ?? this.followers, 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/features/notification/representation/widgets/notification_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/notification/representation/bloc/notification_bloc.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | 5 | class NotificationIcon extends StatelessWidget { 6 | const NotificationIcon({@required this.isActive}); 7 | 8 | final bool isActive; 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Stack( 13 | overflow: Overflow.visible, 14 | children: [ 15 | Icon( 16 | isActive ? Icons.notifications : Icons.notifications_none_outlined, 17 | color: isActive ? Theme.of(context).primaryColor : Theme.of(context).accentColor, 18 | ), 19 | BlocBuilder( 20 | builder: (context, state) { 21 | if (state is FetchingNotificationsComplete) { 22 | return state.notificationCount < 1 23 | ? SizedBox.shrink() 24 | : Positioned( 25 | right: -7, 26 | top: -8, 27 | child: Container( 28 | padding: const EdgeInsets.all(4), 29 | decoration: BoxDecoration( 30 | color: Theme.of(context).primaryColor, 31 | shape: BoxShape.circle, 32 | border: Border.all( 33 | color: Theme.of(context).scaffoldBackgroundColor, 34 | width: 2), 35 | ), 36 | child: Text( 37 | state.notificationCount.toString(), 38 | style: TextStyle( 39 | fontSize: 10, fontWeight: FontWeight.w700), 40 | ), 41 | ), 42 | ); 43 | } 44 | return SizedBox.shrink(); 45 | }, 46 | ), 47 | ], 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/features/notification/model/notification_model_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:fc_twitter/features/notification/data/model/notification_model.dart'; 4 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 5 | import 'package:flutter_test/flutter_test.dart'; 6 | import 'package:mockito/mockito.dart'; 7 | 8 | import '../../../fixtures/fixture_reader.dart'; 9 | import '../../../mocks/mocks.dart'; 10 | 11 | void main() { 12 | MockDocumentSnapshot documentSnapshot; 13 | NotificationModel notificationModel; 14 | NotificationEntity notificationEntity; 15 | 16 | setUp(() { 17 | documentSnapshot = MockDocumentSnapshot(); 18 | notificationModel = notificationModelFixture(); 19 | notificationEntity = notificationEntityFixture(); 20 | }); 21 | 22 | group('NotificationModel', () { 23 | test('should be a sub type of NotificationEntity', () { 24 | expect(notificationModel, isA()); 25 | }); 26 | 27 | test('should return a valid model wwhen converting from entity', () { 28 | final result = NotificationModel.fromEntity(notificationEntity); 29 | 30 | expect(result, equals(notificationModel)); 31 | }); 32 | 33 | test('should return a valid model wwhen converting to entity', () { 34 | final result = notificationModel.toEntity(); 35 | 36 | expect(result, equals(notificationEntity)); 37 | }); 38 | 39 | test('should return a valid model wwhen converting from documentSnapshot', () { 40 | when(documentSnapshot.data()) 41 | .thenReturn(json.decode(jsonNotificationFixture())); 42 | final result = NotificationModel.fromDoc(documentSnapshot); 43 | 44 | expect(result, equals(notificationModel)); 45 | }); 46 | 47 | test('should return a JSON map containing proper data when converting to document', () { 48 | final result = notificationModel.toMap(); 49 | 50 | final expected = { 51 | 'userId': '001', 52 | 'userProfile': docReference, 53 | 'tweet': docReference, 54 | 'isSeen': false, 55 | }; 56 | 57 | expect(result, equals(expected)); 58 | }); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /lib/features/notification/representation/pages/notification_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/notification/representation/widgets/all_notifications.dart'; 2 | import 'package:fc_twitter/features/notification/representation/widgets/mentions.dart'; 3 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 4 | import 'package:fc_twitter/features/profile/representation/bloc/profile_bloc.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | import 'package:flutter_icons/flutter_icons.dart'; 8 | 9 | class NotificationScreen extends StatelessWidget { 10 | @override 11 | Widget build(BuildContext context) { 12 | final theme = Theme.of(context); 13 | final profile = context.select( 14 | (bloc) => bloc.state.userProfile, 15 | ); 16 | return DefaultTabController( 17 | length: 2, 18 | child: Scaffold( 19 | appBar: AppBar( 20 | backgroundColor: theme.scaffoldBackgroundColor, 21 | elevation: 1, 22 | leading: IconButton( 23 | onPressed: () => Scaffold.of(context).openDrawer(), 24 | icon: Icon(Foundation.list, color: theme.primaryColor), 25 | ), 26 | title: Text('Notifications', style: theme.textTheme.headline6), 27 | actions: [ 28 | IconButton( 29 | icon: Icon( 30 | AntDesign.setting, 31 | color: theme.primaryColor, 32 | ), 33 | onPressed: () {}), 34 | ], 35 | bottom: TabBar( 36 | indicatorColor: theme.primaryColor, 37 | labelPadding: const EdgeInsets.only(bottom: 10), 38 | labelColor: theme.primaryColor, 39 | unselectedLabelColor: theme.accentColor, 40 | tabs: [ 41 | Text('All'), 42 | Text('Mentions'), 43 | ], 44 | ), 45 | ), 46 | body: TabBarView( 47 | children: [ 48 | AllNotifications(), 49 | Mentions(), 50 | ], 51 | )), 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/features/timeline/representation/bloc/comment_bloc.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:fc_twitter/features/timeline/domain/repository/timeline_repository.dart.dart'; 4 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 5 | import 'package:flutter_bloc/flutter_bloc.dart'; 6 | 7 | class CommentEvent extends Equatable { 8 | @override 9 | List get props => []; 10 | } 11 | 12 | class FetchComments extends CommentEvent { 13 | final TweetEntity tweet; 14 | 15 | FetchComments({this.tweet}); 16 | } 17 | 18 | class CommentState extends Equatable { 19 | @override 20 | List get props => []; 21 | } 22 | 23 | class InitialCommentState extends CommentState {} 24 | 25 | class FetchingComments extends CommentState {} 26 | 27 | class FetchingCommentsError extends CommentState { 28 | final String message; 29 | 30 | FetchingCommentsError({this.message}); 31 | 32 | @override 33 | List get props => [message]; 34 | } 35 | 36 | class FetchingCommentsComplete extends CommentState { 37 | final Stream> commentStream; 38 | 39 | FetchingCommentsComplete({this.commentStream}); 40 | } 41 | 42 | class CommentBloc extends Bloc { 43 | final TimeLineRepository timeLineRepository; 44 | CommentBloc({CommentState initialState, this.timeLineRepository}) : super(initialState); 45 | 46 | @override 47 | Stream mapEventToState(CommentEvent event) async*{ 48 | if (event is FetchComments) { 49 | yield* _mapFetchCommentsToState(event.tweet); 50 | } 51 | } 52 | 53 | Stream _mapFetchCommentsToState(TweetEntity tweet) async* { 54 | yield FetchingComments(); 55 | final sendEither = await timeLineRepository.fetchComments(tweet); 56 | yield* sendEither.fold( 57 | (failure) async* { 58 | yield FetchingCommentsError(message: failure.message); 59 | }, 60 | (converter) async* { 61 | // converter.fromCommentQuery(converter.commentQuery).listen((event) { 62 | // print(event); 63 | // }); 64 | yield FetchingCommentsComplete( 65 | commentStream: converter.fromQueryToTweets(converter.query)); 66 | }, 67 | ); 68 | } 69 | } -------------------------------------------------------------------------------- /lib/features/tweeting/representation/widgets/like_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 2 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 3 | import 'package:fc_twitter/features/tweeting/representation/bloc/bloc.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_bloc/flutter_bloc.dart'; 6 | import 'package:flutter_icons/flutter_icons.dart'; 7 | 8 | class LikeButton extends StatelessWidget { 9 | const LikeButton({ 10 | Key key, 11 | @required UserProfileEntity profile, 12 | @required TweetEntity tweet, 13 | }) : _profile = profile, 14 | _tweet = tweet, 15 | super(key: key); 16 | 17 | final UserProfileEntity _profile; 18 | final TweetEntity _tweet; 19 | 20 | bool isLiked() { 21 | if (_tweet == null || _profile == null) return false; 22 | return _tweet.likedBy?.any((element) => element.path.endsWith(_profile?.id)); 23 | } 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | bool isTweetLiked = isLiked(); 28 | return GestureDetector( 29 | onTap: () { 30 | if (_profile == null) return; 31 | context.read().add( 32 | isTweetLiked 33 | ? UnlikeTweet( 34 | userProfile: _profile, 35 | tweet: _tweet, 36 | ) 37 | : LikeTweet( 38 | userProfile: _profile, 39 | tweet: _tweet, 40 | ), 41 | ); 42 | }, 43 | child: Row( 44 | mainAxisSize: MainAxisSize.min, 45 | children: [ 46 | Icon( 47 | isTweetLiked 48 | ? MaterialCommunityIcons.heart 49 | : MaterialCommunityIcons.heart_outline, 50 | size: 18, 51 | color: isTweetLiked ? Colors.red : Theme.of(context).accentColor, 52 | ), 53 | SizedBox(width: 5), 54 | Text( 55 | '${_tweet.likedBy.length}', 56 | style: TextStyle( 57 | color: isTweetLiked ? Colors.red : Theme.of(context).accentColor, 58 | ), 59 | ), 60 | ], 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/features/tweeting/representation/bloc/tweeting_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 3 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 4 | 5 | class TweetingEvent extends Equatable { 6 | @override 7 | List get props => []; 8 | } 9 | 10 | class SendTweet extends TweetingEvent { 11 | final UserProfileEntity userProfile; 12 | final TweetEntity tweet; 13 | 14 | SendTweet({this.tweet, this.userProfile}); 15 | 16 | @override 17 | List get props => [tweet]; 18 | 19 | } 20 | class LikeTweet extends TweetingEvent { 21 | final UserProfileEntity userProfile; 22 | final TweetEntity tweet; 23 | 24 | LikeTweet({this.tweet, this.userProfile}); 25 | 26 | @override 27 | List get props => [tweet, userProfile]; 28 | } 29 | 30 | class UnlikeTweet extends TweetingEvent { 31 | final UserProfileEntity userProfile; 32 | final TweetEntity tweet; 33 | 34 | UnlikeTweet({this.tweet, this.userProfile}); 35 | 36 | @override 37 | List get props => [tweet, userProfile]; 38 | } 39 | 40 | class Retweet extends TweetingEvent { 41 | final UserProfileEntity userProfile; 42 | final TweetEntity tweet; 43 | 44 | Retweet({this.tweet, this.userProfile}); 45 | 46 | @override 47 | List get props => [tweet, userProfile]; 48 | } 49 | 50 | class UndoRetweet extends TweetingEvent { 51 | final UserProfileEntity userProfile; 52 | final TweetEntity tweet; 53 | 54 | UndoRetweet({this.tweet, this.userProfile}); 55 | 56 | @override 57 | List get props => [tweet, userProfile]; 58 | } 59 | 60 | class Comment extends TweetingEvent { 61 | final UserProfileEntity userProfile; 62 | final TweetEntity tweet; 63 | final TweetEntity comment; 64 | 65 | Comment({this.tweet, this.comment, this.userProfile}); 66 | 67 | @override 68 | List get props => [tweet, comment, userProfile]; 69 | } 70 | 71 | class QuoteTweet extends TweetingEvent { 72 | final UserProfileEntity userProfile; 73 | final TweetEntity tweet; 74 | final TweetEntity quoteTweet; 75 | 76 | QuoteTweet({this.tweet, this.quoteTweet, this.userProfile}); 77 | 78 | @override 79 | List get props => [tweet, quoteTweet, userProfile]; 80 | } -------------------------------------------------------------------------------- /lib/features/profile/representation/bloc/image_picker_bloc.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'dart:io'; 3 | 4 | import 'package:equatable/equatable.dart'; 5 | import 'package:fc_twitter/features/profile/domain/repository/profile_repository.dart.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | import 'package:image_picker/image_picker.dart'; 8 | 9 | class ImagePickerEvent extends Equatable { 10 | @override 11 | List get props => []; 12 | } 13 | 14 | class PickImage extends ImagePickerEvent { 15 | final ImageSource imageSource; 16 | final bool isCoverPhoto; 17 | 18 | PickImage({this.imageSource, this.isCoverPhoto}); 19 | } 20 | 21 | class ImagePickerState extends Equatable { 22 | final File pickedProfileImage; 23 | final File pickedCoverImage; 24 | 25 | ImagePickerState({this.pickedProfileImage, this.pickedCoverImage}); 26 | @override 27 | List get props => [pickedProfileImage, pickedCoverImage]; 28 | } 29 | 30 | class InitialImagePickerState extends ImagePickerState {} 31 | 32 | class PickedProfileImage extends ImagePickerState { 33 | final File pickedProfileImage; 34 | 35 | PickedProfileImage({this.pickedProfileImage}) : super(pickedProfileImage: pickedProfileImage); 36 | } 37 | 38 | class PickedCoverImage extends ImagePickerState { 39 | final File pickedCoverImage; 40 | 41 | PickedCoverImage({this.pickedCoverImage}) : super(pickedCoverImage: pickedCoverImage); 42 | } 43 | 44 | class ImagePickerBloc extends Bloc { 45 | final ProfileRepository profileRepository; 46 | ImagePickerBloc({ImagePickerState initialState, this.profileRepository}) : super(initialState); 47 | 48 | @override 49 | Stream mapEventToState(ImagePickerEvent event) async*{ 50 | if (event is PickImage) { 51 | yield* _mapPickImageToEvent(event.imageSource, event.isCoverPhoto); 52 | } 53 | } 54 | 55 | Stream _mapPickImageToEvent( 56 | ImageSource source, bool isCoverPhoto) async* { 57 | final imageEither = await profileRepository.pickImage(source, isCoverPhoto); 58 | yield* imageEither.fold((failure) async* { 59 | }, (image) async* { 60 | yield isCoverPhoto 61 | ? PickedCoverImage(pickedCoverImage: image) 62 | : PickedProfileImage(pickedProfileImage: image); 63 | }); 64 | } 65 | } -------------------------------------------------------------------------------- /test/mocks/mocks.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:cloud_firestore/cloud_firestore.dart'; 3 | import 'package:fc_twitter/features/authentication/domain/repository/user_repository.dart'; 4 | import 'package:fc_twitter/features/notification/domain/repository/notification_repository.dart'; 5 | import 'package:fc_twitter/features/profile/domain/repository/profile_repository.dart.dart'; 6 | import 'package:fc_twitter/features/settings/domain/repository/settings_repository.dart'; 7 | import 'package:fc_twitter/features/timeline/domain/repository/timeline_repository.dart.dart'; 8 | import 'package:fc_twitter/features/tweeting/domain/repository/tweeting_repository.dart'; 9 | import 'package:firebase_auth/firebase_auth.dart'; 10 | import 'package:firebase_messaging/firebase_messaging.dart'; 11 | import 'package:firebase_storage/firebase_storage.dart'; 12 | import 'package:http/http.dart'; 13 | import 'package:mockito/mockito.dart'; 14 | 15 | // Repositories 16 | class MockUserRepository extends Mock implements UserRepository {} 17 | 18 | class MockSettingsRepository extends Mock implements SettingsRepository {} 19 | 20 | class MockTimeLineRepository extends Mock implements TimeLineRepository {} 21 | 22 | class MockTweetingRepository extends Mock implements TweetingRepository {} 23 | 24 | class MockProfileRepository extends Mock implements ProfileRepository {} 25 | 26 | class MockNotificationRepository extends Mock implements NotificationRepository {} 27 | 28 | 29 | // Externals 30 | class MockCollectionReference extends Mock implements CollectionReference {} 31 | 32 | class MockQuery extends Mock implements Query {} 33 | 34 | class MockFirebaseFirestore extends Mock implements FirebaseFirestore {} 35 | 36 | class MockFirebaseStorage extends Mock implements FirebaseStorage {} 37 | 38 | class MockClient extends Mock implements Client {} 39 | 40 | class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} 41 | 42 | class MockReference extends Mock implements Reference {} 43 | 44 | class MockUserCredential extends Mock implements UserCredential {} 45 | 46 | class MockDocumentSnapshot extends Mock implements DocumentSnapshot {} 47 | 48 | class MockFireBaseAuth extends Mock implements FirebaseAuth {} 49 | 50 | class MockFireBaseUser extends Mock implements User {} 51 | 52 | class MockDocumentReference extends Mock implements DocumentReference {} 53 | 54 | 55 | -------------------------------------------------------------------------------- /lib/features/tweeting/representation/widgets/tweet_image_display.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:cached_network_image/cached_network_image.dart'; 4 | import 'package:fc_twitter/core/util/config.dart'; 5 | 6 | class TweetImageDisplay extends StatelessWidget { 7 | const TweetImageDisplay({ 8 | Key key, 9 | @required this.tweet, 10 | this.isQuote = false, 11 | }) : super(key: key); 12 | 13 | final TweetEntity tweet; 14 | final bool isQuote; 15 | 16 | Widget _buildImage(BuildContext context, String url) { 17 | final theme = Theme.of(context); 18 | return CachedNetworkImage( 19 | imageUrl: url, 20 | imageBuilder: (_, imageProvider) => Container( 21 | decoration: BoxDecoration( 22 | image: DecorationImage(image: imageProvider, fit: BoxFit.cover), 23 | ), 24 | ), 25 | placeholder: (_, __) => Container( 26 | decoration: BoxDecoration( 27 | color: theme.accentColor, 28 | ), 29 | ), 30 | fit: BoxFit.cover, 31 | ); 32 | } 33 | 34 | Widget _getLayout(BuildContext context, TweetEntity tweet) { 35 | switch (tweet.images.length) { 36 | case 1: 37 | return Expanded( 38 | child: _buildImage(context, tweet.images[0]), 39 | ); 40 | break; 41 | case 2: 42 | return Row( 43 | children: [ 44 | Expanded( 45 | child: _buildImage(context, tweet.images[0]), 46 | ), 47 | SizedBox(width: 5), 48 | Expanded( 49 | child: _buildImage(context, tweet.images[1]), 50 | ) 51 | ], 52 | ); 53 | break; 54 | default: 55 | return Container(); 56 | } 57 | } 58 | 59 | @override 60 | Widget build(BuildContext context) { 61 | return Container( 62 | margin: const EdgeInsets.only(top: 8), 63 | height: Config.yMargin(context, 25), 64 | child: ClipRRect( 65 | borderRadius: isQuote 66 | ? BorderRadius.only( 67 | bottomLeft: Radius.circular(10), 68 | bottomRight: Radius.circular(10), 69 | ) 70 | : BorderRadius.circular(10), 71 | child: _getLayout(context, tweet), 72 | ), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/features/settings/bloc/theme_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/util/themes.dart'; 3 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 4 | import 'package:fc_twitter/features/settings/representation/bloc/theme_bloc.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_test/flutter_test.dart'; 7 | import 'package:mockito/mockito.dart'; 8 | 9 | import '../../../mocks/mocks.dart'; 10 | 11 | void main() { 12 | ThemeBloc settingsBloc; 13 | ThemeEntity themeEntity; 14 | MockSettingsRepository mockSettingsRepository; 15 | 16 | setUp(() { 17 | mockSettingsRepository = MockSettingsRepository(); 18 | settingsBloc = ThemeBloc( 19 | appTheme: AppTheme(ThemeData()), 20 | settingsRepository: mockSettingsRepository, 21 | ); 22 | }); 23 | 24 | group('change theme event', () { 25 | test('should emit AppTheme with light theme option', () { 26 | themeEntity = ThemeEntity(isLight: true, isDim: false, isLightsOut: true); 27 | when(mockSettingsRepository.changeTheme(themeEntity)).thenAnswer( 28 | (_) => Future.value(Right(themeEntity)), 29 | ); 30 | 31 | final expected = [AppTheme(themeOptions[ThemeOptions.Light])]; 32 | 33 | expectLater(settingsBloc, emitsInOrder(expected)); 34 | settingsBloc.add(ChangeTheme(themeEntity)); 35 | }); 36 | 37 | test('should emit AppTheme with dim theme option', () { 38 | themeEntity = ThemeEntity(isLight: false, isDim: true, isLightsOut: false); 39 | when(mockSettingsRepository.changeTheme(themeEntity)).thenAnswer( 40 | (_) => Future.value(Right(themeEntity)), 41 | ); 42 | 43 | final expected = [AppTheme(themeOptions[DarkThemeOptions.Dim])]; 44 | 45 | expectLater(settingsBloc, emitsInOrder(expected)); 46 | settingsBloc.add(ChangeTheme(themeEntity)); 47 | }); 48 | 49 | test('should emit AppTheme with lightsOut theme option', () { 50 | themeEntity = ThemeEntity(isLight: false, isDim: false, isLightsOut: true); 51 | when(mockSettingsRepository.changeTheme(themeEntity)).thenAnswer( 52 | (_) => Future.value(Right(themeEntity)), 53 | ); 54 | 55 | final expected = [AppTheme(themeOptions[DarkThemeOptions.LightsOut])]; 56 | 57 | expectLater(settingsBloc, emitsInOrder(expected)); 58 | settingsBloc.add(ChangeTheme(themeEntity)); 59 | }); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/user_tab_likes.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/profile_tabs_bloc.dart'; 3 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 4 | import 'package:fc_twitter/features/tweeting/representation/widgets/tweet_item.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | 8 | class UserTabLikes extends StatefulWidget { 9 | final UserProfileEntity userProfile; 10 | 11 | UserTabLikes({@required this.userProfile}); 12 | @override 13 | _UserTabLikesState createState() => _UserTabLikesState(); 14 | } 15 | 16 | class _UserTabLikesState extends State { 17 | @override 18 | void initState() { 19 | super.initState(); 20 | Future.delayed(Duration.zero).then((_) { 21 | context.read().add(FetchUserLikes(userId: widget.userProfile.id)); 22 | }); 23 | } 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return BlocBuilder( 28 | buildWhen: (_, currentState) { 29 | return currentState is FetchingUserLikesComplete; 30 | }, 31 | builder: (context, state) { 32 | if (state is FetchingUserLikes) { 33 | return Center(child: CircularProgressIndicator()); 34 | } 35 | if (state is FetchingUserLikesFailed) { 36 | return Center(child: Text(state.message)); 37 | } 38 | if (state is FetchingUserLikesComplete) { 39 | return StreamBuilder>( 40 | stream: state.content, 41 | builder: (context, snapshot) { 42 | return snapshot.hasData && snapshot.data.isNotEmpty 43 | ? ListView.builder( 44 | padding: const EdgeInsets.all(10), 45 | itemCount: snapshot.data.length, 46 | itemBuilder: (ctx, index) => TweetItem( 47 | key: ValueKey(snapshot.data[index].id), 48 | tweet: snapshot.data[index], 49 | profile: widget.userProfile, 50 | ), 51 | ) 52 | : Center(child: Text('No Likes')); 53 | }, 54 | ); 55 | } 56 | return Center(child: Text('Something went wrong')); 57 | }, 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/features/profile/bloc/image_picker_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:fc_twitter/core/error/failure.dart'; 5 | import 'package:fc_twitter/features/profile/domain/repository/profile_repository.dart.dart'; 6 | import 'package:fc_twitter/features/profile/representation/bloc/image_picker_bloc.dart'; 7 | import 'package:flutter_test/flutter_test.dart'; 8 | import 'package:image_picker/image_picker.dart'; 9 | import 'package:mockito/mockito.dart'; 10 | 11 | import '../../../mocks/mocks.dart'; 12 | 13 | void main() { 14 | ProfileRepository mockProfileRepository; 15 | ImagePickerBloc imagePickerBloc; 16 | 17 | setUp(() { 18 | mockProfileRepository = MockProfileRepository(); 19 | imagePickerBloc = ImagePickerBloc( 20 | initialState: InitialImagePickerState(), 21 | profileRepository: mockProfileRepository, 22 | ); 23 | }); 24 | 25 | group('imagePicker bloc PickImage event', () { 26 | test('should emit a [PickedProfileImage] when successful', () async { 27 | final imageFile = File('image'); 28 | when(mockProfileRepository.pickImage(any, any)).thenAnswer( 29 | (_) => Future.value(Right(imageFile)), 30 | ); 31 | 32 | final expectations = [ 33 | PickedProfileImage(pickedProfileImage: imageFile), 34 | ]; 35 | 36 | expectLater(imagePickerBloc, emitsInOrder(expectations)); 37 | imagePickerBloc.add(PickImage(imageSource: ImageSource.gallery, isCoverPhoto: false)); 38 | }); 39 | 40 | test('should emit a [PickedCoverImage] when successful', () async { 41 | final imageFile = File('image'); 42 | when(mockProfileRepository.pickImage(any, any)).thenAnswer( 43 | (_) => Future.value(Right(imageFile)), 44 | ); 45 | 46 | final expectations = [ 47 | PickedCoverImage(pickedCoverImage: imageFile), 48 | ]; 49 | 50 | expectLater(imagePickerBloc, emitsInOrder(expectations)); 51 | imagePickerBloc.add(PickImage(imageSource: ImageSource.gallery, isCoverPhoto: true)); 52 | }); 53 | 54 | test('should emit a nothing when unsuccessful', () async { 55 | when(mockProfileRepository.pickImage(any, any)).thenAnswer( 56 | (_) => Future.value(Left(ProfileFailure())), 57 | ); 58 | 59 | final expectations = []; 60 | 61 | expectLater(imagePickerBloc, emitsInOrder(expectations)); 62 | imagePickerBloc.add(PickImage(imageSource: ImageSource.gallery)); 63 | }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/user_tab_media.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/profile_tabs_bloc.dart'; 3 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 4 | import 'package:fc_twitter/features/tweeting/representation/widgets/tweet_item.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | 8 | class UserTabMedias extends StatefulWidget { 9 | final UserProfileEntity userProfile; 10 | 11 | UserTabMedias({@required this.userProfile}); 12 | @override 13 | _UserTabMediasState createState() => _UserTabMediasState(); 14 | } 15 | 16 | class _UserTabMediasState extends State { 17 | @override 18 | void initState() { 19 | super.initState(); 20 | Future.delayed(Duration.zero).then((_) { 21 | context.read().add(FetchUserMedias(userId: widget.userProfile.id)); 22 | }); 23 | } 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return BlocBuilder( 28 | buildWhen: (_, currentState) { 29 | return currentState is FetchingUserMediasComplete; 30 | }, 31 | builder: (context, state) { 32 | if (state is FetchingUserMedias) { 33 | return Center(child: CircularProgressIndicator()); 34 | } 35 | if (state is FetchingUserMediasFailed) { 36 | return Center(child: Text(state.message)); 37 | } 38 | if (state is FetchingUserMediasComplete) { 39 | return StreamBuilder>( 40 | stream: state.content, 41 | builder: (context, snapshot) { 42 | return snapshot.hasData && snapshot.data.isNotEmpty 43 | ? ListView.builder( 44 | padding: const EdgeInsets.all(10), 45 | itemCount: snapshot.data.length, 46 | itemBuilder: (ctx, index) => TweetItem( 47 | key: ValueKey(snapshot.data[index].id), 48 | tweet: snapshot.data[index], 49 | profile: widget.userProfile, 50 | ), 51 | ) 52 | : Center(child: Text('No Medias')); 53 | }, 54 | ); 55 | } 56 | return Center(child: Text('Something went wrong')); 57 | }, 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/features/tweeting/representation/widgets/media_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/core/util/config.dart'; 2 | import 'package:fc_twitter/features/tweeting/representation/bloc/tweet_media_bloc.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | import 'package:flutter_icons/flutter_icons.dart'; 6 | 7 | class MediaPreview extends StatelessWidget { 8 | final int index; 9 | MediaPreview(this.index); 10 | @override 11 | Widget build(BuildContext context) { 12 | switch (index) { 13 | case 0: 14 | return Container( 15 | margin: const EdgeInsets.symmetric(horizontal: 5), 16 | width: Config.xMargin(context, 20), 17 | child: IconButton( 18 | icon: Icon( 19 | Icons.camera_alt_outlined, 20 | color: Theme.of(context).primaryColor, 21 | size: Config.xMargin(context, 10), 22 | ), 23 | onPressed: () {}, 24 | ), 25 | decoration: BoxDecoration( 26 | borderRadius: BorderRadius.circular(10), 27 | border: 28 | Border.all(color: Theme.of(context).accentColor, width: 0.5), 29 | ), 30 | ); 31 | break; 32 | case 14: 33 | return Container( 34 | margin: const EdgeInsets.symmetric(horizontal: 5), 35 | width: Config.xMargin(context, 20), 36 | child: IconButton( 37 | icon: Icon( 38 | Feather.image, 39 | color: Theme.of(context).primaryColor, 40 | size: Config.xMargin(context, 10), 41 | ), 42 | onPressed: () { 43 | context.read().add(PickMultiImages()); 44 | }, 45 | ), 46 | decoration: BoxDecoration( 47 | borderRadius: BorderRadius.circular(10), 48 | border: Border.all( 49 | width: 0.5, 50 | color: Theme.of(context).accentColor, 51 | ), 52 | ), 53 | ); 54 | default: 55 | return Container( 56 | width: Config.xMargin(context, 20), 57 | margin: const EdgeInsets.symmetric(horizontal: 5), 58 | decoration: BoxDecoration( 59 | borderRadius: BorderRadius.circular(10), 60 | border: Border.all( 61 | width: 0.5, 62 | color: Theme.of(context).accentColor, 63 | ), 64 | ), 65 | ); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/user_tab_replies.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/profile_tabs_bloc.dart'; 3 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 4 | import 'package:fc_twitter/features/tweeting/representation/widgets/tweet_item.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | 8 | class UserTabReplies extends StatefulWidget { 9 | final UserProfileEntity userProfile; 10 | 11 | UserTabReplies({@required this.userProfile}); 12 | @override 13 | _UserTabRepliesState createState() => _UserTabRepliesState(); 14 | } 15 | 16 | class _UserTabRepliesState extends State { 17 | @override 18 | void initState() { 19 | super.initState(); 20 | Future.delayed(Duration.zero).then((_) { 21 | context.read().add(FetchUserReplies(userId: widget.userProfile.id)); 22 | }); 23 | } 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return BlocBuilder( 28 | buildWhen: (_, currentState) { 29 | return currentState is FetchingUserRepliesComplete; 30 | }, 31 | builder: (context, state) { 32 | if (state is FetchingUserReplies) { 33 | return Center(child: CircularProgressIndicator()); 34 | } 35 | if (state is FetchingUserRepliesFailed) { 36 | return Center(child: Text(state.message)); 37 | } 38 | if (state is FetchingUserRepliesComplete) { 39 | return StreamBuilder>( 40 | stream: state.content, 41 | builder: (context, snapshot) { 42 | return snapshot.hasData && snapshot.data.isNotEmpty 43 | ? ListView.builder( 44 | padding: const EdgeInsets.all(10), 45 | itemCount: snapshot.data.length, 46 | itemBuilder: (ctx, index) => TweetItem( 47 | key: ValueKey(snapshot.data[index].id), 48 | tweet: snapshot.data[index], 49 | profile: widget.userProfile, 50 | ), 51 | ) 52 | : Center(child: Text('No Replies')); 53 | }, 54 | ); 55 | } 56 | return Center(child: Text('Something went wrong')); 57 | }, 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new FileNotFoundException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | apply plugin: 'com.google.gms.google-services' 28 | 29 | android { 30 | compileSdkVersion 29 31 | 32 | sourceSets { 33 | main.java.srcDirs += 'src/main/kotlin' 34 | } 35 | 36 | lintOptions { 37 | disable 'InvalidPackage' 38 | } 39 | 40 | defaultConfig { 41 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 42 | applicationId "com.onuifeanyi.fc_twitter" 43 | minSdkVersion 19 44 | targetSdkVersion 29 45 | versionCode flutterVersionCode.toInteger() 46 | versionName flutterVersionName 47 | multiDexEnabled true 48 | } 49 | 50 | buildTypes { 51 | release { 52 | // TODO: Add your own signing config for the release build. 53 | // Signing with the debug keys for now, so `flutter run --release` works. 54 | signingConfig signingConfigs.debug 55 | } 56 | } 57 | } 58 | 59 | flutter { 60 | source '../..' 61 | } 62 | 63 | dependencies { 64 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 65 | implementation platform('com.google.firebase:firebase-bom:26.1.0') 66 | implementation 'com.google.firebase:firebase-analytics-ktx' 67 | implementation 'com.google.firebase:firebase-firestore-ktx' 68 | implementation 'com.google.firebase:firebase-auth-ktx' 69 | implementation 'com.google.firebase:firebase-storage-ktx' 70 | implementation 'com.android.support:multidex:1.0.3' 71 | } 72 | -------------------------------------------------------------------------------- /test/features/timeline/bloc/timeline_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:dartz/dartz.dart'; 5 | import 'package:fc_twitter/core/error/failure.dart'; 6 | import 'package:fc_twitter/core/model/stream_converter.dart'; 7 | import 'package:fc_twitter/features/timeline/representation/bloc/timeline_bloc.dart'; 8 | import 'package:flutter_test/flutter_test.dart'; 9 | import 'package:mockito/mockito.dart'; 10 | 11 | import '../../../mocks/mocks.dart'; 12 | 13 | void main() { 14 | MockCollectionReference collectionReference; 15 | // ignore: close_sinks 16 | StreamController streamController; 17 | TimeLineBloc timeLineBloc; 18 | MockTimeLineRepository mockTimeLineRepository; 19 | 20 | setUp(() { 21 | collectionReference = MockCollectionReference(); 22 | mockTimeLineRepository = MockTimeLineRepository(); 23 | streamController = StreamController(); 24 | timeLineBloc = TimeLineBloc( 25 | initialState: InitialTimeLineState(), 26 | timeLineRepository: mockTimeLineRepository, 27 | ); 28 | }); 29 | 30 | test(('confirm initial bloc state'), () { 31 | expect(timeLineBloc.state, equals(InitialTimeLineState())); 32 | }); 33 | group('timeline bloc fetchTweets event', () { 34 | 35 | test( 36 | 'should emit [FetchingTweet, FetchingTweetComplete] when successful', 37 | () async { 38 | when(collectionReference.snapshots()) 39 | .thenAnswer((_) => streamController.stream); 40 | when(mockTimeLineRepository.fetchTweets()).thenAnswer( 41 | (_) => Future.value( 42 | Right(StreamConverter(collection: collectionReference))), 43 | ); 44 | 45 | final expectations = [ 46 | FetchingTweet(), 47 | FetchingTweetComplete(), 48 | ]; 49 | expectLater(timeLineBloc, emitsInOrder(expectations)); 50 | 51 | timeLineBloc.add(FetchTweet()); 52 | }); 53 | 54 | test( 55 | 'should emit [FetchingTweet, FetchingTweetFailed] when it fails', 56 | () async { 57 | when(mockTimeLineRepository.fetchTweets()).thenAnswer( 58 | (_) => Future.value( 59 | Left(TimeLineFailure(message: 'Failed to load tweets'))), 60 | ); 61 | final expectations = [ 62 | FetchingTweet(), 63 | FetchingTweetError(message: 'Failed to load tweets'), 64 | ]; 65 | expectLater(timeLineBloc, emitsInOrder(expectations)); 66 | 67 | timeLineBloc.add(FetchTweet()); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/user_tab_tweets.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/profile_tabs_bloc.dart'; 3 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 4 | import 'package:fc_twitter/features/tweeting/representation/widgets/tweet_item.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | 8 | class UserTabTweets extends StatefulWidget { 9 | final UserProfileEntity userProfile; 10 | 11 | UserTabTweets({@required this.userProfile}); 12 | @override 13 | _UserTabTweetsState createState() => _UserTabTweetsState(); 14 | } 15 | 16 | class _UserTabTweetsState extends State { 17 | @override 18 | void initState() { 19 | super.initState(); 20 | Future.delayed(Duration.zero).then((_) { 21 | context 22 | .read() 23 | .add(FetchUserTweets(userId: widget.userProfile.id)); 24 | }); 25 | } 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return BlocBuilder( 30 | buildWhen: (_, currentState) { 31 | return currentState is FetchingUserTweetsComplete; 32 | }, 33 | builder: (context, state) { 34 | if (state is FetchingUserTweets) { 35 | print('fetching'); 36 | return Center(child: CircularProgressIndicator()); 37 | } 38 | if (state is FetchingUserTweetsFailed) { 39 | return Center(child: Text(state.message)); 40 | } 41 | if (state is FetchingUserTweetsComplete) { 42 | return StreamBuilder>( 43 | stream: state.content, 44 | builder: (context, snapshot) { 45 | return snapshot.hasData && snapshot.data.isNotEmpty 46 | ? ListView.builder( 47 | padding: const EdgeInsets.all(10), 48 | itemCount: snapshot.data.length, 49 | itemBuilder: (ctx, index) => TweetItem( 50 | key: ValueKey(snapshot.data[index].id), 51 | tweet: snapshot.data[index], 52 | profile: widget.userProfile, 53 | ), 54 | ) 55 | : Center(child: Text('No Tweets')); 56 | }, 57 | ); 58 | } 59 | return Center(child: Text('Something went wrong')); 60 | }, 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/features/tweeting/model/tweet_model_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:fc_twitter/features/tweeting/data/model/tweet_model.dart'; 5 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 6 | import 'package:flutter_test/flutter_test.dart'; 7 | import 'package:mockito/mockito.dart'; 8 | 9 | import '../../../fixtures/fixture_reader.dart'; 10 | import '../../../mocks/mocks.dart'; 11 | 12 | void main() { 13 | MockDocumentSnapshot documentSnapshot = MockDocumentSnapshot(); 14 | final tweetModel = tweetModelFixture(); 15 | 16 | final tweetEntity = tweetEntityFixture(); 17 | group('tweetModel', () { 18 | test('should be a sub type of TweetEntity', () async { 19 | expect(tweetModel, isA()); 20 | }); 21 | 22 | test('should return a valid model wwhen converting from entity', () async { 23 | final result = TweetModel.fromEntity(tweetEntity); 24 | 25 | expect(result, equals(tweetModel)); 26 | }); 27 | 28 | test('should return a valid model wwhen converting to entity', () async { 29 | final result = tweetModel.toEntity(); 30 | 31 | expect(result, equals(tweetEntity)); 32 | }); 33 | 34 | test('should return a valid model wwhen converting from snapshot', 35 | () async { 36 | when(documentSnapshot.id).thenReturn('001'); 37 | final data = json.decode(jsonTweetFixture()); 38 | data['timeStamp'] = Timestamp(0, 0); 39 | when(documentSnapshot.data()).thenReturn(data); 40 | 41 | final result = TweetModel.fromSnapShot(documentSnapshot); 42 | 43 | expect(result, equals(tweetModel)); 44 | }); 45 | 46 | test( 47 | 'should return a JSON map containing proper data when converting to document', 48 | () async { 49 | final result = tweetModel.toMap(); 50 | 51 | final expected = { 52 | 'id': '001', 53 | 'userId': '001', 54 | 'message': 'hello world', 55 | 'timeStamp': Timestamp(0, 0), 56 | 'userProfile': docReference, 57 | 'retweetersProfile': null, 58 | 'quoteTo': null, 59 | 'commentTo': null, 60 | 'retweetTo': null, 61 | 'noOfComments': 0, 62 | 'images': null, 63 | 'retweetedBy': null, 64 | 'quotedBy': null, 65 | 'likedBy': null, 66 | 'isQuote': null, 67 | 'hasMedia': false, 68 | 'isRetweet': false, 69 | 'isComment': false 70 | }; 71 | 72 | expect(result, equals(expected)); 73 | }); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /test/features/timeline/bloc/comment_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:dartz/dartz.dart'; 5 | import 'package:fc_twitter/core/error/failure.dart'; 6 | import 'package:fc_twitter/core/model/stream_converter.dart'; 7 | import 'package:fc_twitter/features/timeline/representation/bloc/comment_bloc.dart'; 8 | import 'package:flutter_test/flutter_test.dart'; 9 | import 'package:mockito/mockito.dart'; 10 | 11 | import '../../../fixtures/fixture_reader.dart'; 12 | import '../../../mocks/mocks.dart'; 13 | 14 | void main() { 15 | MockCollectionReference collectionReference; 16 | // ignore: close_sinks 17 | StreamController streamController; 18 | CommentBloc commentBloc; 19 | MockTimeLineRepository mockTimeLineRepository; 20 | 21 | setUp(() { 22 | collectionReference = MockCollectionReference(); 23 | mockTimeLineRepository = MockTimeLineRepository(); 24 | streamController = StreamController(); 25 | commentBloc = CommentBloc( 26 | initialState: InitialCommentState(), 27 | timeLineRepository: mockTimeLineRepository, 28 | ); 29 | }); 30 | 31 | test(('confirm initial bloc state'), () { 32 | expect(commentBloc.state, equals(InitialCommentState())); 33 | }); 34 | group('commentBloc fetchComments event', () { 35 | 36 | test( 37 | 'should emit [FetchingComments, FetchingCommentsComplete] when successful', 38 | () async { 39 | when(collectionReference.snapshots()) 40 | .thenAnswer((_) => streamController.stream); 41 | when(mockTimeLineRepository.fetchComments(any)).thenAnswer( 42 | (_) => Future.value( 43 | Right(StreamConverter(query: collectionReference))), 44 | ); 45 | 46 | final expectations = [ 47 | FetchingComments(), 48 | FetchingCommentsComplete(), 49 | ]; 50 | expectLater(commentBloc, emitsInOrder(expectations)); 51 | 52 | commentBloc.add(FetchComments(tweet: tweetEntityFixture())); 53 | }); 54 | 55 | test( 56 | 'should emit [FetchingComments, FetchingCommentsFailed] when it fails', 57 | () async { 58 | when(mockTimeLineRepository.fetchComments(any)).thenAnswer( 59 | (_) => Future.value( 60 | Left(TimeLineFailure(message: 'Failed to load comments'))), 61 | ); 62 | final expectations = [ 63 | FetchingComments(), 64 | FetchingCommentsError(message: 'Failed to load comments'), 65 | ]; 66 | expectLater(commentBloc, emitsInOrder(expectations)); 67 | 68 | commentBloc.add(FetchComments(tweet: tweetEntityFixture())); 69 | }); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /lib/features/timeline/representation/widgets/comment_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:fc_twitter/features/profile/data/model/user_profile_model.dart'; 3 | import 'package:fc_twitter/features/timeline/representation/bloc/comment_bloc.dart'; 4 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 5 | import 'package:fc_twitter/features/tweeting/representation/widgets/tweet_item.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter_bloc/flutter_bloc.dart'; 8 | 9 | class CommentBuilder extends StatelessWidget { 10 | @override 11 | Widget build(BuildContext context) { 12 | return BlocBuilder( 13 | builder: (context, state) { 14 | if (state is FetchingComments) { 15 | return Padding( 16 | padding: const EdgeInsets.symmetric(vertical: 10.0), 17 | child: CircularProgressIndicator( 18 | backgroundColor: Theme.of(context).primaryColor), 19 | ); 20 | } 21 | if (state is FetchingCommentsComplete) { 22 | return StreamBuilder>( 23 | stream: state.commentStream, 24 | builder: (context, snapshot) { 25 | return snapshot.hasData 26 | ? Padding( 27 | padding: const EdgeInsets.all(10.0), 28 | child: Column( 29 | children: snapshot.data 30 | .map( 31 | (tweet) => FutureBuilder( 32 | future: tweet.userProfile.get(), 33 | builder: (context, profileData) { 34 | if (profileData.connectionState == 35 | ConnectionState.waiting) { 36 | return SizedBox.shrink(); 37 | } 38 | final profile = 39 | UserProfileModel.fromDoc(profileData.data); 40 | return TweetItem( 41 | tweet: tweet, 42 | profile: profile, 43 | ); 44 | }), 45 | ) 46 | .toList(), 47 | ), 48 | ) 49 | : Center(child: Text('nothing')); 50 | }, 51 | ); 52 | } 53 | return SizedBox(); 54 | }, 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/features/authentication/representation/bloc/auth_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/authentication/domain/repository/user_repository.dart'; 2 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 3 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | 6 | import 'auth_event.dart'; 7 | import 'auth_state.dart'; 8 | 9 | class AuthBloc extends Bloc { 10 | final UserRepository userRepository; 11 | AuthBloc({ 12 | AuthState initialState, 13 | this.userRepository, 14 | }) : super(initialState); 15 | 16 | @override 17 | Stream mapEventToState(AuthEvent event) async* { 18 | if (event is SignUp) { 19 | yield* _mapSignupToState(event.user); 20 | } 21 | if (event is Login) { 22 | yield* _mapLoginToState(event.user); 23 | } 24 | if (event is LogOut) { 25 | userRepository.logOutUser(); 26 | } 27 | } 28 | 29 | Stream _mapSignupToState(UserEntity user) async* { 30 | yield AuthInProgress(); 31 | 32 | final response = await userRepository.signUpNewUser(user); 33 | yield* response.fold((failure) async* { 34 | yield AuthFailed(message: failure.message); 35 | }, (credentials) async* { 36 | final userProfile = UserProfileEntity( 37 | id: credentials.user.uid, 38 | name: user.email.split('@').first, 39 | userName: '@' + user.userName, 40 | dateJoined: _getDate(), 41 | ); 42 | final savedEither = await userRepository.saveUserDetail(userProfile); 43 | yield* savedEither.fold((failure) async* { 44 | yield AuthFailed(message: failure.message); 45 | }, (success) async* { 46 | yield AuthComplete(); 47 | }); 48 | }); 49 | // if signup is successful and saving fails the newly created user should probably be deleted 50 | } 51 | 52 | Stream _mapLoginToState(UserEntity user) async* { 53 | yield AuthInProgress(); 54 | final response = await userRepository.logInUser(user); 55 | yield* response.fold((failure) async* { 56 | yield AuthFailed(message: failure.message); 57 | }, (credentials) async* { 58 | yield AuthComplete(); 59 | }); 60 | } 61 | 62 | static String _getDate() { 63 | final date = DateTime.now(); 64 | final months = [ 65 | 'January', 66 | 'Februrary', 67 | 'March', 68 | 'April', 69 | 'May', 70 | 'June', 71 | 'July', 72 | 'August', 73 | 'September', 74 | 'October', 75 | 'November', 76 | 'December', 77 | ]; 78 | return 'Joined ${months[date.month - 1]} ${date.year}'; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 8 | 12 | 19 | 23 | 27 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 43 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /lib/features/timeline/representation/bloc/timeline_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:fc_twitter/features/timeline/domain/repository/timeline_repository.dart.dart'; 5 | import 'package:flutter_bloc/flutter_bloc.dart'; 6 | 7 | class TimeLineEvent extends Equatable { 8 | @override 9 | List get props => []; 10 | } 11 | 12 | class FetchTweet extends TimeLineEvent {} 13 | 14 | 15 | class TimeLineState extends Equatable { 16 | @override 17 | List get props => []; 18 | 19 | void showSnackBar( 20 | BuildContext context, 21 | GlobalKey scaffoldKey, 22 | String message, 23 | int time, { 24 | bool isError = false, 25 | }) { 26 | scaffoldKey.currentState.removeCurrentSnackBar(); 27 | scaffoldKey.currentState.showSnackBar( 28 | SnackBar( 29 | content: Text( 30 | message, 31 | style: TextStyle(color: Colors.white), 32 | ), 33 | duration: Duration(seconds: time), 34 | backgroundColor: isError 35 | ? Theme.of(context).errorColor 36 | : Theme.of(context).primaryColor, 37 | ), 38 | ); 39 | } 40 | } 41 | 42 | class InitialTimeLineState extends TimeLineState {} 43 | 44 | class FetchingTweet extends TimeLineState {} 45 | 46 | class FetchingTweetError extends TimeLineState { 47 | final String message; 48 | 49 | FetchingTweetError({this.message}); 50 | 51 | @override 52 | List get props => [message]; 53 | } 54 | 55 | class FetchingTweetComplete extends TimeLineState { 56 | final Stream> tweetStream; 57 | 58 | FetchingTweetComplete({this.tweetStream}); 59 | } 60 | 61 | class TimeLineBloc extends Bloc { 62 | final TimeLineRepository timeLineRepository; 63 | TimeLineBloc({TimeLineState initialState, this.timeLineRepository}) 64 | : super(initialState); 65 | 66 | @override 67 | Stream mapEventToState(TimeLineEvent event) async* { 68 | if (event is FetchTweet) { 69 | yield* _mapFetchTweetToState(); 70 | } 71 | } 72 | 73 | Stream _mapFetchTweetToState() async* { 74 | yield FetchingTweet(); 75 | final sendEither = await timeLineRepository.fetchTweets(); 76 | yield* sendEither.fold( 77 | (failure) async* { 78 | yield FetchingTweetError(message: failure.message); 79 | }, 80 | (converter) async* { 81 | // converter.fromCollectionToTweets(converter.collection).listen((event) { 82 | // print(event); 83 | // }); 84 | yield FetchingTweetComplete( 85 | tweetStream: converter.fromCollectionToTweets(converter.collection)); 86 | }, 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/features/authentication/data/repository/user_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/error/failure.dart'; 3 | import 'package:fc_twitter/features/authentication/domain/repository/user_repository.dart'; 4 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 5 | import 'package:fc_twitter/features/profile/data/model/user_profile_model.dart'; 6 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 7 | import 'package:firebase_auth/firebase_auth.dart'; 8 | import 'package:cloud_firestore/cloud_firestore.dart'; 9 | import 'package:firebase_messaging/firebase_messaging.dart'; 10 | 11 | class UserRepositoryImpl extends UserRepository { 12 | final FirebaseAuth firebaseAuth; 13 | final FirebaseMessaging firebaseMessaging; 14 | final FirebaseFirestore firebaseFirestore; 15 | 16 | UserRepositoryImpl({this.firebaseAuth, this.firebaseFirestore, this.firebaseMessaging}) 17 | : assert(firebaseAuth != null), 18 | assert(firebaseFirestore != null); 19 | 20 | @override 21 | Future> logInUser(UserEntity user) async { 22 | UserCredential userCredential; 23 | try { 24 | userCredential = await firebaseAuth.signInWithEmailAndPassword( 25 | email: user.email, password: user.password); 26 | return Right(userCredential); 27 | } on FirebaseAuthException catch (error) { 28 | return Left(AuthFailure(message: error.message)); 29 | } catch (error) { 30 | return Left(AuthFailure(message: 'Login failed')); 31 | } 32 | } 33 | 34 | @override 35 | Future> logOutUser() async{ 36 | await firebaseAuth.signOut(); 37 | return Right(true); 38 | } 39 | 40 | @override 41 | Future> signUpNewUser( 42 | UserEntity user) async { 43 | UserCredential userCredential; 44 | try { 45 | userCredential = await firebaseAuth.createUserWithEmailAndPassword( 46 | email: user.email, password: user.password); 47 | return Right(userCredential); 48 | } on FirebaseAuthException catch (error) { 49 | return Left(AuthFailure(message: error.message)); 50 | } catch (error) { 51 | return Left(AuthFailure(message: 'Sign up failed')); 52 | } 53 | } 54 | 55 | @override 56 | Future> saveUserDetail( 57 | UserProfileEntity userProfile) async { 58 | try { 59 | final userDetail = userProfile.copyWith(token: await firebaseMessaging.getToken()); 60 | await firebaseFirestore 61 | .collection('users') 62 | .doc(userDetail.id) 63 | .set(UserProfileModel.fromEntity(userDetail).toMap()); 64 | return Right(true); 65 | } catch (error) { 66 | return Left(AuthFailure(message: 'Saving failed')); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/features/authentication/representation/pages/auth_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/core/util/config.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_icons/flutter_icons.dart'; 4 | 5 | import 'auth_form.dart'; 6 | 7 | class AuthScreen extends StatelessWidget { 8 | @override 9 | Widget build(BuildContext context) { 10 | return Scaffold( 11 | appBar: AppBar( 12 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 13 | elevation: 0, 14 | title: Icon( 15 | FontAwesome.twitter, 16 | color: Theme.of(context).primaryColor, 17 | ), 18 | centerTitle: true, 19 | ), 20 | body: Padding( 21 | padding: const EdgeInsets.all(25), 22 | child: Column( 23 | crossAxisAlignment: CrossAxisAlignment.stretch, 24 | children: [ 25 | Spacer(), 26 | Text( 27 | 'See what\'s happening in the world right now.', 28 | style: TextStyle( 29 | fontSize: Config.xMargin(context, 8), 30 | fontWeight: FontWeight.bold, 31 | ), 32 | ), 33 | SizedBox(height: Config.yMargin(context, 2)), 34 | GestureDetector( 35 | onTap: () => Navigator.pushNamed(context, AuthForm.pageId, 36 | arguments: false), 37 | child: Container( 38 | padding: const EdgeInsets.symmetric(vertical: 5), 39 | decoration: BoxDecoration( 40 | color: Theme.of(context).primaryColor, 41 | borderRadius: BorderRadius.circular(30)), 42 | child: Text( 43 | 'Create account', 44 | textAlign: TextAlign.center, 45 | style: TextStyle( 46 | fontWeight: FontWeight.bold, 47 | fontSize: Config.xMargin(context, 6), 48 | color: Colors.white), 49 | ), 50 | ), 51 | ), 52 | Spacer(), 53 | Row( 54 | children: [ 55 | Text( 56 | 'Have an account already?', 57 | style: TextStyle(fontSize: Config.xMargin(context, 4)), 58 | ), 59 | GestureDetector( 60 | onTap: () => Navigator.pushNamed(context, AuthForm.pageId, 61 | arguments: true), 62 | child: Text( 63 | ' Log in', 64 | style: TextStyle( 65 | color: Theme.of(context).primaryColor, 66 | fontSize: Config.xMargin(context, 4) 67 | ), 68 | ), 69 | ), 70 | ], 71 | ) 72 | ], 73 | ), 74 | ), 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/features/timeline/representation/pages/home_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/profile_bloc.dart'; 3 | import 'package:fc_twitter/features/timeline/representation/bloc/timeline_bloc.dart'; 4 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 5 | import 'package:fc_twitter/features/tweeting/representation/widgets/tweet_item.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter_bloc/flutter_bloc.dart'; 8 | import 'package:flutter_icons/flutter_icons.dart'; 9 | 10 | class HomeScreen extends StatelessWidget { 11 | @override 12 | Widget build(BuildContext context) { 13 | final profile = context.select( 14 | (bloc) => bloc.state.userProfile, 15 | ); 16 | return Scaffold( 17 | appBar: AppBar( 18 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 19 | elevation: 1, 20 | leading: IconButton( 21 | onPressed: () => Scaffold.of(context).openDrawer(), 22 | icon: Icon(Foundation.list, color: Theme.of(context).primaryColor), 23 | ), 24 | title: IconButton( 25 | icon: Icon( 26 | FontAwesome.twitter, 27 | color: Theme.of(context).primaryColor, 28 | ), 29 | onPressed: () {}), 30 | actions: [ 31 | IconButton( 32 | icon: Icon( 33 | Icons.star_border_outlined, 34 | color: Theme.of(context).primaryColor, 35 | ), 36 | onPressed: () {}, 37 | ), 38 | ], 39 | centerTitle: true, 40 | ), 41 | body: BlocBuilder( 42 | buildWhen: (_, currentState) { 43 | return currentState is FetchingTweetComplete; 44 | }, 45 | builder: (context, state) { 46 | if (state is FetchingTweetComplete) { 47 | return StreamBuilder>( 48 | stream: state.tweetStream, 49 | builder: (context, snapshot) { 50 | return snapshot.hasData 51 | ? ListView.builder( 52 | padding: const EdgeInsets.all(10), 53 | itemCount: snapshot.data.length, 54 | itemBuilder: (ctx, index) => TweetItem( 55 | key: ValueKey(snapshot.data[index].id), 56 | tweet: snapshot.data[index], 57 | profile: profile, 58 | ), 59 | ) 60 | : Center(child: Text('nothing')); 61 | }, 62 | ); 63 | } 64 | return Center(child: CircularProgressIndicator()); 65 | }, 66 | ), 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/drawer_user_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/core/util/config.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/profile_bloc.dart'; 3 | import 'package:firebase_auth/firebase_auth.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_bloc/flutter_bloc.dart'; 6 | 7 | import 'avatar.dart'; 8 | 9 | class DrawerUserInfo extends StatelessWidget { 10 | @override 11 | Widget build(BuildContext context) { 12 | final _customLightStyle = TextStyle( 13 | color: Theme.of(context).accentColor, 14 | fontSize: Config.xMargin(context, 4), 15 | ); 16 | return Container( 17 | padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 18 | width: double.infinity, 19 | child: BlocBuilder( 20 | buildWhen: (_, currentState) { 21 | return currentState.userProfile.id == FirebaseAuth.instance.currentUser.uid; 22 | }, 23 | builder: (context, state) { 24 | if (state.userProfile == null) { 25 | return Container( 26 | height: Config.yMargin(context, 16.5), 27 | ); 28 | } 29 | final profile = state.userProfile; 30 | return Column( 31 | crossAxisAlignment: CrossAxisAlignment.start, 32 | children: [ 33 | Avatar(userProfile: profile, radius: 30), 34 | SizedBox(height: 8), 35 | Text( 36 | profile.name, 37 | style: TextStyle( 38 | fontSize: Config.xMargin(context, 5), 39 | fontWeight: FontWeight.bold), 40 | ), 41 | SizedBox(height: 5), 42 | Text( 43 | profile.userName, 44 | style: _customLightStyle, 45 | ), 46 | SizedBox(height: 8), 47 | Row( 48 | children: [ 49 | Text( 50 | '${profile.following.length}', 51 | style: TextStyle( 52 | fontSize: Config.xMargin(context, 4), 53 | fontWeight: FontWeight.bold), 54 | ), 55 | Text( 56 | ' Followng', 57 | style: _customLightStyle, 58 | ), 59 | SizedBox(width: 10), 60 | Text( 61 | '${profile.followers.length}', 62 | style: TextStyle( 63 | fontSize: Config.xMargin(context, 4), 64 | fontWeight: FontWeight.bold), 65 | ), 66 | Text( 67 | ' Followers', 68 | style: _customLightStyle, 69 | ), 70 | ], 71 | ) 72 | ], 73 | ); 74 | }, 75 | ), 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/features/profile/model/user_profile_model_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:fc_twitter/features/profile/data/model/user_profile_model.dart'; 4 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 5 | import 'package:flutter_test/flutter_test.dart'; 6 | import 'package:mockito/mockito.dart'; 7 | 8 | import '../../../fixtures/fixture_reader.dart'; 9 | import '../../../mocks/mocks.dart'; 10 | 11 | void main() { 12 | MockDocumentSnapshot documentSnapshot; 13 | UserProfileModel userModel; 14 | UserProfileEntity userEntity; 15 | 16 | setUp(() { 17 | documentSnapshot = MockDocumentSnapshot(); 18 | userModel = userProfileModelFixture(); 19 | userEntity = userProfileEntityFixture(); 20 | }); 21 | 22 | group('UserProfileEntity', () { 23 | test('should return an updated entity with copyWith', () { 24 | UserProfileEntity user = userEntity; 25 | expect(user.id, equals('001')); 26 | expect(user.name, equals('ifeanyi')); 27 | expect(user.location, equals('Abuja')); 28 | 29 | user = user.copyWith(name: 'onuoha', location: 'Kaduna'); 30 | 31 | expect(user.id, equals('001')); 32 | expect(user.name, equals('onuoha')); 33 | expect(user.location, equals('Kaduna')); 34 | }); 35 | }); 36 | 37 | group('UserProfileModel', () { 38 | test('should be a sub type of UserProfileEntity', () async { 39 | expect(userModel, isA()); 40 | }); 41 | 42 | test('should return a valid model wwhen converting from entity', () async { 43 | final result = UserProfileModel.fromEntity(userEntity); 44 | 45 | expect(result, equals(userModel)); 46 | }); 47 | 48 | test('should return a valid model wwhen converting to entity', () async { 49 | final result = userModel.toEntity(); 50 | 51 | expect(result, equals(userEntity)); 52 | }); 53 | 54 | test('should return a valid model wwhen converting from documentSnapshot', 55 | () async { 56 | when(documentSnapshot.id).thenReturn('001'); 57 | when(documentSnapshot.data()) 58 | .thenReturn(json.decode(jsonUserProfileFixture())); 59 | 60 | final result = UserProfileModel.fromDoc(documentSnapshot); 61 | 62 | expect(result, equals(userModel)); 63 | }); 64 | 65 | test( 66 | 'should return a JSON map containing proper data when converting to document', 67 | () async { 68 | final result = userModel.toMap(); 69 | 70 | final expected = { 71 | 'id': '001', 72 | 'name': 'ifeanyi', 73 | 'userName': 'onuoha', 74 | 'location': 'Abuja', 75 | 'token': null, 76 | 'bio': null, 77 | 'website': null, 78 | 'dateOfBirth': null, 79 | 'dateJoined': null, 80 | 'profilePhoto': null, 81 | 'coverPhoto': null, 82 | 'following': null, 83 | 'followers': null 84 | }; 85 | 86 | expect(result, equals(expected)); 87 | }); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /test/features/timeline/repository/timeline_repository_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:dartz/dartz.dart'; 3 | import 'package:fc_twitter/core/error/failure.dart'; 4 | import 'package:fc_twitter/core/model/stream_converter.dart'; 5 | import 'package:fc_twitter/features/timeline/data/repository/timeline_repository.dart'; 6 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 7 | import 'package:flutter_test/flutter_test.dart'; 8 | import 'package:mockito/mockito.dart'; 9 | 10 | import '../../../fixtures/fixture_reader.dart'; 11 | import '../../../mocks/mocks.dart'; 12 | 13 | void main() { 14 | TweetEntity tweetEntity; 15 | FirebaseFirestore mockFirebaseFirestore; 16 | Query query; 17 | TimeLineRepositoryImpl timeLineRepositoryImpl; 18 | MockCollectionReference collectionReference; 19 | 20 | setUp(() { 21 | tweetEntity = tweetEntityFixture(); 22 | mockFirebaseFirestore = MockFirebaseFirestore(); 23 | query = MockQuery(); 24 | collectionReference = MockCollectionReference(); 25 | timeLineRepositoryImpl = 26 | TimeLineRepositoryImpl(firebaseFirestore: mockFirebaseFirestore); 27 | }); 28 | 29 | group('timeline repository fetchTweets', () { 30 | test('should return a StreamConverter when fetch tweet successful', 31 | () async { 32 | when(mockFirebaseFirestore.collection(any)) 33 | .thenReturn(collectionReference); 34 | 35 | final response = await timeLineRepositoryImpl.fetchTweets(); 36 | verify(timeLineRepositoryImpl.fetchTweets()); 37 | 38 | expect(response, Right(StreamConverter(collection: collectionReference))); 39 | }); 40 | 41 | test('should return a FetchingFailure when fetch tweet fails', () async { 42 | when(mockFirebaseFirestore.collection(any)).thenThrow(Error()); 43 | 44 | final response = await timeLineRepositoryImpl.fetchTweets(); 45 | verify(timeLineRepositoryImpl.fetchTweets()); 46 | 47 | expect(response, Left(TimeLineFailure(message: 'Failed to load tweets'))); 48 | }); 49 | }); 50 | 51 | group('timeline repository fetchComments', () { 52 | test('should return a StreamConverter when successful', () async { 53 | when(mockFirebaseFirestore.collection(any)) 54 | .thenReturn(collectionReference); 55 | when(collectionReference.where(any, isEqualTo: anyNamed('isEqualTo'))) 56 | .thenReturn(query); 57 | 58 | final response = await timeLineRepositoryImpl.fetchComments(tweetEntity); 59 | 60 | expect(response, Right(StreamConverter(collection: collectionReference))); 61 | }); 62 | 63 | test('should return a FetchingFailure when fetchComment fails', () async { 64 | when(mockFirebaseFirestore.collection(any)).thenThrow(Error()); 65 | 66 | final response = await timeLineRepositoryImpl.fetchComments(tweetEntity); 67 | 68 | expect( 69 | response, Left(TimeLineFailure(message: 'Failed to load comments'))); 70 | }); 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/profile_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/profile/representation/bloc/image_picker_bloc.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_icons/flutter_icons.dart'; 5 | import 'package:image_picker/image_picker.dart'; 6 | 7 | class ProfileImage extends StatelessWidget { 8 | final String imageUrl; 9 | 10 | ProfileImage({@required this.imageUrl}); 11 | @override 12 | Widget build(BuildContext context) { 13 | return BlocBuilder( 14 | buildWhen: (_, currentState) { 15 | return currentState is PickedProfileImage; 16 | }, 17 | builder: (context, state) { 18 | return Stack( 19 | children: [ 20 | CircleAvatar( 21 | radius: 30, 22 | backgroundColor: Theme.of(context).primaryColor, 23 | backgroundImage: state.pickedProfileImage != null 24 | ? FileImage(state.pickedProfileImage) 25 | : imageUrl.isNotEmpty ? NetworkImage(imageUrl) : null, 26 | ), 27 | GestureDetector( 28 | onTap: () async { 29 | final choice = await showDialog( 30 | context: context, 31 | builder: (context) => AlertDialog( 32 | contentPadding: const EdgeInsets.symmetric( 33 | horizontal: 15, vertical: 10), 34 | content: Column( 35 | crossAxisAlignment: CrossAxisAlignment.stretch, 36 | mainAxisSize: MainAxisSize.min, 37 | children: [ 38 | FlatButton( 39 | materialTapTargetSize: 40 | MaterialTapTargetSize.shrinkWrap, 41 | onPressed: () => 42 | Navigator.pop(context, ImageSource.camera), 43 | child: Text('Take photo'), 44 | ), 45 | FlatButton( 46 | materialTapTargetSize: 47 | MaterialTapTargetSize.shrinkWrap, 48 | onPressed: () => 49 | Navigator.pop(context, ImageSource.gallery), 50 | child: Text('Choose existing photo'), 51 | ), 52 | ], 53 | ), 54 | ), 55 | ); 56 | if (choice == null) { 57 | return; 58 | } 59 | context 60 | .read() 61 | .add(PickImage(imageSource: choice, isCoverPhoto: false)); 62 | }, 63 | child: CircleAvatar( 64 | radius: 30, 65 | backgroundColor: Colors.black12, 66 | child: Icon(Feather.camera), 67 | ), 68 | ) 69 | ], 70 | ); 71 | }, 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/features/tweeting/domain/entity/tweet_entity.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class TweetEntity extends Equatable { 6 | final String id; 7 | final String userId; 8 | final DocumentReference userProfile; 9 | final DocumentReference retweetersProfile; 10 | final String message; 11 | final DocumentReference quoteTo; 12 | final DocumentReference retweetTo; 13 | final DocumentReference commentTo; 14 | final int noOfComments; 15 | final List retweetedBy; 16 | final List likedBy; 17 | final List quotedBy; 18 | final List images; 19 | final bool isQuote; 20 | final bool isRetweet; 21 | final bool isComment; 22 | final bool hasMedia; 23 | final Timestamp timeStamp; 24 | 25 | TweetEntity({ 26 | @required this.id, 27 | this.userId, 28 | this.userProfile, 29 | @required this.message, 30 | @required this.timeStamp, 31 | this.retweetersProfile, 32 | this.retweetTo, 33 | this.quoteTo, 34 | this.commentTo, 35 | this.noOfComments, 36 | this.images, 37 | this.retweetedBy, 38 | this.likedBy, 39 | this.quotedBy, 40 | this.isQuote, 41 | this.isRetweet, 42 | this.hasMedia, 43 | this.isComment 44 | }); 45 | 46 | TweetEntity copyWith({ 47 | String userId, 48 | String message, 49 | DocumentReference userProfile, 50 | List likedBy, 51 | List quotedBy, 52 | List images, 53 | List retweetedBy, 54 | int noOfComments, 55 | DocumentReference quoteTo, 56 | DocumentReference commentTo, 57 | DocumentReference retweetTo, 58 | bool isQuote, 59 | bool isRetweet, 60 | bool isComment, 61 | bool hasMedia, 62 | DocumentReference retweetersProfile, 63 | }) { 64 | return TweetEntity( 65 | id: this.id, 66 | userId: userId ?? this.userId, 67 | userProfile: userProfile ?? this.userProfile, 68 | message: message ?? this.message, 69 | quoteTo: quoteTo ?? this.quoteTo, 70 | commentTo: commentTo ?? this.commentTo, 71 | retweetTo: retweetTo ?? this.retweetTo, 72 | noOfComments: noOfComments ?? this.noOfComments, 73 | retweetersProfile: retweetersProfile ?? this.retweetersProfile, 74 | retweetedBy: retweetedBy ?? this.retweetedBy, 75 | likedBy: likedBy ?? this.likedBy, 76 | quotedBy: quotedBy ?? this.quotedBy, 77 | images: images ?? this.images, 78 | isQuote: isQuote ?? this.isQuote, 79 | isRetweet: isRetweet ?? this.isRetweet, 80 | hasMedia: hasMedia ?? this.hasMedia, 81 | isComment: isComment ?? this.isComment, 82 | timeStamp: this.timeStamp, 83 | ); 84 | } 85 | 86 | String getTime(Timestamp timeStamp) { 87 | final time = DateTime.now().subtract(Duration(seconds: timeStamp.seconds)); 88 | if (time.day > 1) { 89 | return '${time.day}d'; 90 | } else if (time.hour > 1) { 91 | return '${time.hour}h'; 92 | } else if (time.minute >= 1) { 93 | return '${time.minute}m'; 94 | } else { 95 | return '${time.second}s'; 96 | } 97 | } 98 | 99 | @override 100 | List get props => [message, timeStamp]; 101 | } 102 | -------------------------------------------------------------------------------- /lib/features/notification/representation/bloc/notification_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 3 | import 'package:fc_twitter/features/notification/domain/repository/notification_repository.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | 6 | class NotificationEvent extends Equatable { 7 | @override 8 | List get props => []; 9 | } 10 | 11 | class FetchNotifications extends NotificationEvent { 12 | final String userId; 13 | 14 | FetchNotifications({this.userId}); 15 | } 16 | 17 | class MarkAllAsSeen extends NotificationEvent { 18 | final String userId; 19 | 20 | MarkAllAsSeen({this.userId}); 21 | } 22 | 23 | class NotificationState extends Equatable { 24 | @override 25 | List get props => []; 26 | } 27 | 28 | class InitialNotificationState extends NotificationState {} 29 | 30 | class FetchingNotifications extends NotificationState {} 31 | 32 | class FetchingNotificationsError extends NotificationState { 33 | final String message; 34 | 35 | FetchingNotificationsError({this.message}); 36 | 37 | @override 38 | List get props => [message]; 39 | } 40 | 41 | class FetchingNotificationsComplete extends NotificationState { 42 | final Stream> notificationStream; 43 | final int notificationCount; 44 | 45 | FetchingNotificationsComplete( 46 | {this.notificationStream, this.notificationCount}); 47 | 48 | @override 49 | List get props => [notificationStream, notificationCount]; 50 | } 51 | 52 | class NotificationBloc extends Bloc { 53 | final NotificationRepository notificationRepository; 54 | NotificationBloc( 55 | {NotificationState initialState, this.notificationRepository}) 56 | : super(initialState); 57 | 58 | @override 59 | Stream mapEventToState(NotificationEvent event) async* { 60 | if (event is FetchNotifications) { 61 | yield* _mapFetchNotificationsToState(event.userId); 62 | } 63 | if (event is MarkAllAsSeen) { 64 | await notificationRepository.markAllAsSeen(event.userId); 65 | yield* _mapFetchNotificationsToState(event.userId); 66 | } 67 | } 68 | 69 | Stream _mapFetchNotificationsToState( 70 | String userId) async* { 71 | yield FetchingNotifications(); 72 | final fetchEither = await notificationRepository.fetchNotifications(userId); 73 | yield* fetchEither.fold( 74 | (failure) async* { 75 | yield FetchingNotificationsError(message: failure.message); 76 | }, 77 | (converter) async* { 78 | // converter.fromQueryToNotification(converter.query).listen((event) { 79 | // print(event); 80 | // }); 81 | final notificationStream = 82 | converter.fromQueryToNotification(converter.query); 83 | // timeout is needed for the test 84 | final count = await notificationStream 85 | .timeout(Duration(seconds: 15), onTimeout: (sink) => sink.add([])) 86 | .first 87 | .then((value) => value.where((element) => !element.isSeen).length); 88 | print('notification count $count'); 89 | yield FetchingNotificationsComplete( 90 | notificationCount: count, 91 | notificationStream: notificationStream, 92 | ); 93 | }, 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/core/model/stream_converter_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:fc_twitter/core/model/stream_converter.dart'; 5 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 6 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 7 | import 'package:flutter_test/flutter_test.dart'; 8 | import 'package:mockito/mockito.dart'; 9 | 10 | import '../../mocks/mocks.dart'; 11 | 12 | void main() { 13 | CollectionReference collection; 14 | Query query; 15 | FirebaseFirestore firebaseFirestore; 16 | StreamConverter streamConverter; 17 | // ignore: close_sinks 18 | StreamController streamController; 19 | 20 | setUp(() { 21 | firebaseFirestore = MockFirebaseFirestore(); 22 | collection = MockCollectionReference(); 23 | query = MockQuery(); 24 | streamController = StreamController(); 25 | }); 26 | 27 | group('stream converter fromCollectionToTweets', () { 28 | test('Should hold an initial stream of type CollectionReference', () async { 29 | when(firebaseFirestore.collection(any)).thenReturn(collection); 30 | 31 | final tweets = firebaseFirestore.collection('tweets'); 32 | 33 | streamConverter = StreamConverter(collection: tweets); 34 | 35 | expect(streamConverter.collection, isA()); 36 | }); 37 | 38 | test('Should return a stream of type TweetEntity when toTweetModel is called', () async { 39 | when(firebaseFirestore.collection(any)).thenReturn(collection); 40 | when(collection.snapshots()).thenAnswer((_) => streamController.stream); 41 | 42 | final tweets = firebaseFirestore.collection('tweets'); 43 | 44 | streamConverter = StreamConverter(collection: tweets); 45 | 46 | final converted = streamConverter.fromCollectionToTweets(collection); 47 | 48 | expect(converted, isA>>()); 49 | }); 50 | }); 51 | 52 | group('stream converter fromQueryToTweets', () { 53 | test('Should return a stream of type TweetEntity when toTweetModel is called', () async { 54 | when(firebaseFirestore.collection(any)).thenReturn(collection); 55 | when(collection.where(any, isEqualTo: anyNamed('isEqualTo'))).thenReturn(query); 56 | when(query.snapshots()).thenAnswer((_) => streamController.stream); 57 | 58 | final queryResult = firebaseFirestore.collection('tweets').where('id', isEqualTo: '001'); 59 | 60 | streamConverter = StreamConverter(query: queryResult); 61 | 62 | final converted = streamConverter.fromQueryToTweets(queryResult); 63 | 64 | expect(converted, isA>>()); 65 | }); 66 | }); 67 | 68 | group('stream converter fromQueryToNotifications', () { 69 | test('Should return a stream of type NotificationEntity when called', () async { 70 | when(firebaseFirestore.collection(any)).thenReturn(collection); 71 | when(collection.where(any, isEqualTo: anyNamed('isEqualTo'))).thenReturn(query); 72 | when(query.snapshots()).thenAnswer((_) => streamController.stream); 73 | 74 | final queryResult = firebaseFirestore.collection('tweets').where('id', isEqualTo: '001'); 75 | 76 | streamConverter = StreamConverter(query: queryResult); 77 | 78 | final converted = streamConverter.fromQueryToNotification(queryResult); 79 | 80 | expect(converted, isA>>()); 81 | }); 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /lib/features/profile/data/model/user_profile_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class UserProfileModel extends UserProfileEntity { 6 | UserProfileModel({ 7 | @required id, 8 | @required name, 9 | @required userName, 10 | token, 11 | bio, 12 | location, 13 | website, 14 | dateOfBirth, 15 | dateJoined, 16 | profilePhoto, 17 | coverPhoto, 18 | following, 19 | followers, 20 | }) : super( 21 | id: id, 22 | token: token, 23 | name: name, 24 | userName: userName, 25 | bio: bio, 26 | location: location, 27 | website: website, 28 | dateOfBirth: dateOfBirth, 29 | dateJoined: dateJoined, 30 | profilePhoto: profilePhoto, 31 | coverPhoto: coverPhoto, 32 | following: following, 33 | followers: followers, 34 | ); 35 | 36 | factory UserProfileModel.fromDoc(DocumentSnapshot userDoc) { 37 | final data = userDoc.data(); 38 | return UserProfileModel( 39 | id: userDoc.id, 40 | token: data['token'], 41 | name: data['name'], 42 | userName: data['userName'], 43 | bio: data['bio'] ?? '', 44 | location: data['location'] ?? '', 45 | website: data['website'] ?? '', 46 | dateOfBirth: data['dateOfBirth'] ?? '', 47 | dateJoined: data['dateJoined'], 48 | profilePhoto: data['profilePhoto'] ?? '', 49 | coverPhoto: data['coverPhoto'] ?? '', 50 | following: data['following'] ?? List(), 51 | followers: data['followers'] ?? List(), 52 | ); 53 | } 54 | 55 | factory UserProfileModel.fromEntity(UserProfileEntity entity) { 56 | return UserProfileModel( 57 | id: entity.id, 58 | token: entity.token, 59 | name: entity.name, 60 | userName: entity.userName, 61 | bio: entity.bio, 62 | location: entity.location, 63 | website: entity.website, 64 | dateOfBirth: entity.dateOfBirth, 65 | dateJoined: entity.dateJoined, 66 | profilePhoto: entity.profilePhoto, 67 | coverPhoto: entity.coverPhoto, 68 | following: entity.following, 69 | followers: entity.followers, 70 | ); 71 | } 72 | 73 | UserProfileEntity toEntity() { 74 | return UserProfileEntity( 75 | id: this.id, 76 | token: this.token, 77 | name: this.name, 78 | userName: this.userName, 79 | bio: this.bio, 80 | location: this.location, 81 | website: this.website, 82 | dateOfBirth: this.dateOfBirth, 83 | dateJoined: this.dateJoined, 84 | profilePhoto: this.profilePhoto, 85 | coverPhoto: this.coverPhoto, 86 | following: this.following, 87 | followers: this.followers, 88 | ); 89 | } 90 | 91 | Map toMap() { 92 | return { 93 | 'id': this.id, 94 | 'token': this.token, 95 | 'name': this.name, 96 | 'userName': this.userName, 97 | 'bio': this.bio, 98 | 'location': this.location, 99 | 'website': this.website, 100 | 'dateOfBirth': this.dateOfBirth, 101 | 'dateJoined': this.dateJoined, 102 | 'profilePhoto': this.profilePhoto, 103 | 'coverPhoto': this.coverPhoto, 104 | 'following': this.following, 105 | 'followers': this.followers, 106 | }; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/notification/representation/bloc/notification_bloc.dart'; 2 | import 'package:fc_twitter/features/profile/representation/pages/edit_profile_screen.dart'; 3 | import 'package:fc_twitter/features/profile/representation/pages/profile_screen.dart'; 4 | import 'package:fc_twitter/features/settings/representation/bloc/theme_bloc.dart'; 5 | import 'package:fc_twitter/features/timeline/representation/bloc/comment_bloc.dart'; 6 | import 'package:fc_twitter/features/tweeting/representation/bloc/bloc.dart'; 7 | import 'package:fc_twitter/features/timeline/representation/pages/comments_screen.dart'; 8 | import 'package:fc_twitter/features/tweeting/representation/bloc/tweet_media_bloc.dart'; 9 | import 'package:fc_twitter/injection_container.dart'; 10 | import 'package:firebase_auth/firebase_auth.dart'; 11 | import 'package:firebase_core/firebase_core.dart'; 12 | import 'package:flutter/material.dart'; 13 | import 'package:flutter_bloc/flutter_bloc.dart'; 14 | import 'features/authentication/representation/bloc/bloc.dart'; 15 | import 'features/authentication/representation/pages/auth_form.dart'; 16 | import 'features/authentication/representation/pages/auth_screen.dart'; 17 | import 'features/profile/representation/bloc/profile_bloc.dart'; 18 | import 'features/timeline/representation/bloc/timeline_bloc.dart'; 19 | import 'features/tweeting/representation/pages/tweet_screen.dart'; 20 | import 'navigation_screen.dart'; 21 | 22 | void main() async { 23 | WidgetsFlutterBinding.ensureInitialized(); 24 | await Firebase.initializeApp(); 25 | await init(); 26 | runApp( 27 | MultiBlocProvider( 28 | providers: [ 29 | BlocProvider(create: (_) => sl()), 30 | BlocProvider(create: (_) => sl()), 31 | BlocProvider(create: (_) => sl()), 32 | BlocProvider(create: (_) => sl()), 33 | BlocProvider(create: (_) => sl()), 34 | BlocProvider(create: (_) => sl()), 35 | BlocProvider(create: (_) => sl()), 36 | BlocProvider(create: (_) => sl()), 37 | ], 38 | child: MyApp(), 39 | ), 40 | ); 41 | } 42 | 43 | class MyApp extends StatelessWidget { 44 | @override 45 | Widget build(BuildContext context) { 46 | // ignore: close_sinks 47 | final profileBloc = BlocProvider.of(context); 48 | return BlocBuilder( 49 | builder: (context, state) { 50 | return MaterialApp( 51 | title: 'Flutter Demo', 52 | theme: state.theme, 53 | home: StreamBuilder( 54 | stream: FirebaseAuth.instance.authStateChanges(), 55 | builder: (context, snapshot) { 56 | if (snapshot.hasData) { 57 | profileBloc.add(FetchUserProfile(snapshot.data.uid)); 58 | return NavigationScreen(); 59 | } else { 60 | return AuthScreen(); 61 | } 62 | }), 63 | routes: { 64 | TweetScreen.pageId: (ctx) => TweetScreen(), 65 | CommentsScreen.pageId: (ctx) => CommentsScreen(), 66 | AuthForm.pageId: (ctx) => AuthForm(), 67 | NavigationScreen.pageId: (ctx) => NavigationScreen(), 68 | ProfileScreen.pageId: (ctx) => ProfileScreen(), 69 | EditProfileScreen.pageId: (ctx) => EditProfileScreen(), 70 | }, 71 | ); 72 | }, 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/features/notification/bloc/notification_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:dartz/dartz.dart'; 5 | import 'package:fc_twitter/core/error/failure.dart'; 6 | import 'package:fc_twitter/core/model/stream_converter.dart'; 7 | import 'package:fc_twitter/features/notification/representation/bloc/notification_bloc.dart'; 8 | import 'package:flutter_test/flutter_test.dart'; 9 | import 'package:mockito/mockito.dart'; 10 | 11 | import '../../../mocks/mocks.dart'; 12 | 13 | void main() { 14 | MockCollectionReference collectionReference; 15 | // ignore: close_sinks 16 | StreamController streamController; 17 | NotificationBloc notificationBloc; 18 | MockNotificationRepository mockNotificationRepository; 19 | 20 | setUp(() { 21 | collectionReference = MockCollectionReference(); 22 | mockNotificationRepository = MockNotificationRepository(); 23 | streamController = StreamController(); 24 | notificationBloc = NotificationBloc( 25 | initialState: InitialNotificationState(), 26 | notificationRepository: mockNotificationRepository, 27 | ); 28 | }); 29 | 30 | test(('confirm initial bloc state'), () { 31 | expect(notificationBloc.state, equals(InitialNotificationState())); 32 | }); 33 | 34 | group('notification bloc FetchNotifications event', () { 35 | test( 36 | 'should emit [FetchingNotifications, FetchingNotificationsComplete] when successful', 37 | () async { 38 | when(collectionReference.snapshots()) 39 | .thenAnswer((_) => streamController.stream); 40 | when(mockNotificationRepository.fetchNotifications(any)).thenAnswer( 41 | (_) => Future.value(Right(StreamConverter(query: collectionReference))), 42 | ); 43 | 44 | final expectations = [ 45 | FetchingNotifications(), 46 | FetchingNotificationsComplete(), 47 | ]; 48 | expectLater(notificationBloc, emitsInOrder(expectations)); 49 | 50 | notificationBloc.add(FetchNotifications(userId: '001')); 51 | }); 52 | 53 | test( 54 | 'should emit [FetchingNotifications, FetchingNotificationsFailed] when it fails', 55 | () async { 56 | when(mockNotificationRepository.fetchNotifications(any)).thenAnswer( 57 | (_) => Future.value( 58 | Left(NotificationFailure(message: 'Failed to notify'))), 59 | ); 60 | final expectations = [ 61 | FetchingNotifications(), 62 | FetchingNotificationsError(message: 'Failed to notify'), 63 | ]; 64 | expectLater(notificationBloc, emitsInOrder(expectations)); 65 | 66 | notificationBloc.add(FetchNotifications(userId: '001')); 67 | }); 68 | }); 69 | 70 | group('notification bloc MarkAllAsSeen event', () { 71 | test( 72 | 'should emit [FetchingNotifications, FetchingNotificationsComplete] when successful', 73 | () async { 74 | when(mockNotificationRepository.markAllAsSeen(any)).thenAnswer( 75 | (_) => Future.value(Right(true)), 76 | ); 77 | when(mockNotificationRepository.fetchNotifications(any)).thenAnswer( 78 | (_) => Future.value(Right(StreamConverter(query: collectionReference))), 79 | ); 80 | when(collectionReference.snapshots()) 81 | .thenAnswer((_) => streamController.stream); 82 | 83 | final expectations = [ 84 | FetchingNotifications(), 85 | FetchingNotificationsComplete(), 86 | ]; 87 | expectLater(notificationBloc, emitsInOrder(expectations)); 88 | 89 | notificationBloc.add(MarkAllAsSeen(userId: '001')); 90 | }); 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /lib/features/profile/representation/widgets/cover_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/core/util/config.dart'; 2 | import 'package:fc_twitter/features/profile/representation/bloc/image_picker_bloc.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | import 'package:flutter_icons/flutter_icons.dart'; 6 | import 'package:image_picker/image_picker.dart'; 7 | 8 | class CoverImage extends StatelessWidget { 9 | final String imageUrl; 10 | 11 | CoverImage({@required this.imageUrl}); 12 | @override 13 | Widget build(BuildContext context) { 14 | return BlocBuilder( 15 | buildWhen: (_, currentState) { 16 | return currentState is PickedCoverImage; 17 | }, 18 | builder: (context, state) { 19 | return Stack( 20 | children: [ 21 | Container( 22 | height: Config.yMargin(context, 18), 23 | decoration: state.pickedCoverImage != null 24 | ? BoxDecoration( 25 | image: DecorationImage( 26 | image: FileImage(state.pickedCoverImage), 27 | fit: BoxFit.fitWidth, 28 | ), 29 | ) 30 | : BoxDecoration( 31 | color: Theme.of(context).primaryColor, 32 | image: imageUrl.isNotEmpty ? DecorationImage( 33 | fit: BoxFit.fitWidth, 34 | image: 35 | NetworkImage(imageUrl), 36 | ) : null, 37 | ), 38 | ), 39 | GestureDetector( 40 | onTap: () async { 41 | final choice = await showDialog( 42 | context: context, 43 | builder: (context) => AlertDialog( 44 | contentPadding: const EdgeInsets.symmetric( 45 | horizontal: 15, vertical: 10), 46 | content: Column( 47 | crossAxisAlignment: CrossAxisAlignment.stretch, 48 | mainAxisSize: MainAxisSize.min, 49 | children: [ 50 | FlatButton( 51 | materialTapTargetSize: 52 | MaterialTapTargetSize.shrinkWrap, 53 | onPressed: () => 54 | Navigator.pop(context, ImageSource.camera), 55 | child: Text('Take photo'), 56 | ), 57 | FlatButton( 58 | materialTapTargetSize: 59 | MaterialTapTargetSize.shrinkWrap, 60 | onPressed: () => 61 | Navigator.pop(context, ImageSource.gallery), 62 | child: Text('Choose existing photo'), 63 | ), 64 | ], 65 | ), 66 | ), 67 | ); 68 | if (choice == null) { 69 | return; 70 | } 71 | context 72 | .read() 73 | .add(PickImage(imageSource: choice, isCoverPhoto: true)); 74 | }, 75 | child: Container( 76 | height: Config.yMargin(context, 18), 77 | color: Colors.black38, 78 | alignment: Alignment.center, 79 | child: Icon( 80 | Feather.camera, 81 | size: Config.xMargin(context, 10), 82 | color: Colors.white, 83 | ), 84 | ), 85 | ) 86 | ], 87 | ); 88 | }, 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lib/features/authentication/representation/pages/auth_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/authentication/representation/bloc/bloc.dart'; 2 | import 'package:fc_twitter/features/authentication/representation/widgets/login_form.dart'; 3 | import 'package:fc_twitter/features/authentication/representation/widgets/signup_form.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | import 'package:fc_twitter/core/util/config.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter_icons/flutter_icons.dart'; 8 | 9 | class AuthForm extends StatefulWidget { 10 | static const String pageId = '/authForm'; 11 | @override 12 | _AuthFormState createState() => _AuthFormState(); 13 | } 14 | 15 | class _AuthFormState extends State { 16 | final _formKey = GlobalKey(); 17 | final _scaffoldKey = GlobalKey(); 18 | bool _isLogin = true; 19 | 20 | @override 21 | void initState() { 22 | super.initState(); 23 | Future.delayed(Duration.zero).then((value) { 24 | setState(() { 25 | _isLogin = ModalRoute.of(context).settings.arguments; 26 | }); 27 | }); 28 | } 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return Scaffold( 33 | key: _scaffoldKey, 34 | appBar: AppBar( 35 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 36 | elevation: 0, 37 | title: Icon( 38 | FontAwesome.twitter, 39 | color: Theme.of(context).primaryColor, 40 | ), 41 | centerTitle: true, 42 | leading: IconButton( 43 | icon: Icon( 44 | Icons.arrow_back, 45 | color: Theme.of(context).primaryColor, 46 | ), 47 | onPressed: () => Navigator.pop(context), 48 | ), 49 | actions: _isLogin 50 | ? [ 51 | FlatButton( 52 | onPressed: () { 53 | setState(() { 54 | _isLogin = false; 55 | }); 56 | }, 57 | child: Text( 58 | 'Sign up', 59 | style: TextStyle( 60 | color: Theme.of(context).primaryColor, 61 | fontWeight: FontWeight.w700, 62 | fontSize: Config.xMargin(context, 4)), 63 | ), 64 | ), 65 | IconButton( 66 | icon: Icon( 67 | Icons.more_vert_rounded, 68 | color: Theme.of(context).primaryColor, 69 | ), 70 | onPressed: () {}, 71 | ) 72 | ] 73 | : null, 74 | ), 75 | body: BlocConsumer( 76 | listener: (context, state) { 77 | if (state is AuthFailed) { 78 | state.showSnackBar(context, _scaffoldKey, state.message); 79 | } 80 | if (state is AuthComplete) { 81 | Navigator.pop(context); 82 | } 83 | }, 84 | 85 | builder: (context, state) => Form( 86 | key: _formKey, 87 | child: Stack( 88 | children: [ 89 | _isLogin ? LoginForm(_formKey) : SignupForm(_formKey), 90 | if (state.isLoading) ...[ 91 | Container( 92 | alignment: Alignment.center, 93 | color: Theme.of(context) 94 | .scaffoldBackgroundColor 95 | .withOpacity(0.4), 96 | child: CircularProgressIndicator( 97 | backgroundColor: Theme.of(context).primaryColor, 98 | ), 99 | ) 100 | ] 101 | ], 102 | ), 103 | ), 104 | ), 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/features/authentication/representation/widgets/login_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 2 | import 'package:fc_twitter/features/authentication/representation/bloc/bloc.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:fc_twitter/core/util/config.dart'; 5 | import 'package:flutter/material.dart'; 6 | 7 | class LoginForm extends StatefulWidget { 8 | final _formKey; 9 | LoginForm(this._formKey); 10 | @override 11 | _LoginFormState createState() => _LoginFormState(); 12 | } 13 | 14 | class _LoginFormState extends State { 15 | final _loginEmailController = TextEditingController(); 16 | final _loginPasswordController = TextEditingController(); 17 | @override 18 | Widget build(BuildContext context) { 19 | return Padding( 20 | padding: const EdgeInsets.all(15), 21 | child: Column( 22 | crossAxisAlignment: CrossAxisAlignment.start, 23 | children: [ 24 | Text( 25 | 'Log in to Twitter.', 26 | style: TextStyle( 27 | fontSize: Config.xMargin(context, 6), 28 | fontWeight: FontWeight.bold, 29 | ), 30 | ), 31 | SizedBox(height: Config.yMargin(context, 2)), 32 | TextFormField( 33 | controller: _loginEmailController, 34 | decoration: InputDecoration( 35 | labelText: 'Phone or email', 36 | ), 37 | validator: (val) { 38 | if (val.isEmpty) { 39 | return 'Feild cannot be empty'; 40 | } 41 | return null; 42 | }, 43 | ), 44 | TextFormField( 45 | controller: _loginPasswordController, 46 | decoration: InputDecoration( 47 | labelText: 'Password', 48 | ), 49 | validator: (val) { 50 | if (val.isEmpty) { 51 | return 'Feild cannot be empty'; 52 | } 53 | return null; 54 | }, 55 | ), 56 | SizedBox(height: Config.yMargin(context, 2)), 57 | Align( 58 | alignment: Alignment.center, 59 | child: Text( 60 | 'Forgot password?', 61 | textAlign: TextAlign.center, 62 | ), 63 | ), 64 | Spacer(), 65 | Divider( 66 | thickness: 1, 67 | height: 25, 68 | ), 69 | Align( 70 | alignment: Alignment.bottomRight, 71 | child: GestureDetector( 72 | onTap: () { 73 | bool isValid = widget._formKey.currentState.validate(); 74 | if (!isValid) return; 75 | FocusScope.of(context).unfocus(); 76 | context.read().add( 77 | Login( 78 | user: UserEntity( 79 | email: _loginEmailController.text, 80 | password: _loginPasswordController.text, 81 | )), 82 | ); 83 | }, 84 | child: Container( 85 | padding: 86 | const EdgeInsets.symmetric(horizontal: 20, vertical: 8), 87 | decoration: BoxDecoration( 88 | color: Theme.of(context).primaryColor, 89 | borderRadius: BorderRadius.circular(30)), 90 | child: Text( 91 | 'Log in', 92 | style: TextStyle( 93 | fontWeight: FontWeight.bold, 94 | fontSize: Config.xMargin(context, 4), 95 | color: Colors.white), 96 | ), 97 | ), 98 | ), 99 | ), 100 | ], 101 | ), 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/features/notification/data/repository/notification_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:fc_twitter/core/error/failure.dart'; 5 | import 'package:dartz/dartz.dart'; 6 | import 'package:fc_twitter/core/model/stream_converter.dart'; 7 | import 'package:fc_twitter/features/notification/data/model/notification_model.dart'; 8 | import 'package:fc_twitter/features/notification/domain/repository/notification_repository.dart'; 9 | import 'package:fc_twitter/features/profile/data/model/user_profile_model.dart'; 10 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 11 | import 'package:fc_twitter/secrets.dart'; 12 | import 'package:http/http.dart' as http; 13 | import 'package:firebase_messaging/firebase_messaging.dart'; 14 | 15 | class NotificationRepositoryImpl implements NotificationRepository { 16 | final FirebaseFirestore firebaseFirestore; 17 | final FirebaseMessaging firebaseMessaging; 18 | final http.Client httpClient; 19 | 20 | NotificationRepositoryImpl( 21 | {this.firebaseFirestore, this.firebaseMessaging, this.httpClient}); 22 | 23 | @override 24 | Future> sendLikeNotification( 25 | TweetEntity tweet) async { 26 | try { 27 | final String serverToken = SERVER_TOKEN; 28 | final user = await tweet.userProfile 29 | .get() 30 | ?.then((value) => UserProfileModel.fromDoc(value)); 31 | 32 | final notification = NotificationModel( 33 | userId: tweet.userId, 34 | tweet: firebaseFirestore.collection('tweets').doc(tweet.id), 35 | userProfile: tweet.likedBy?.last, 36 | isSeen: false, 37 | ); 38 | 39 | await firebaseFirestore 40 | .collection('notifications') 41 | .add(notification.toMap()); 42 | 43 | final response = await httpClient.post( 44 | 'https://fcm.googleapis.com/fcm/send', 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'Authorization': 'key=$serverToken', 48 | }, 49 | body: jsonEncode( 50 | { 51 | 'notification': { 52 | 'body': 'this is a body', 53 | 'title': 'this is a title' 54 | }, 55 | 'priority': 'high', 56 | 'data': { 57 | 'click_action': 'FLUTTER_NOTIFICATION_CLICK', 58 | 'id': '1', 59 | 'status': 'done' 60 | }, 61 | 'to': user?.token, 62 | }, 63 | ), 64 | ); 65 | 66 | if (response.statusCode == 200) { 67 | return Right(true); 68 | } else { 69 | throw Error(); 70 | } 71 | } catch (error) { 72 | print(error); 73 | return Left(NotificationFailure(message: 'Failed to notify')); 74 | } 75 | } 76 | 77 | @override 78 | Future> fetchNotifications( 79 | String userId) async { 80 | print(userId); 81 | if (userId == null) { 82 | print('userid is null'); 83 | } 84 | try { 85 | final collection = firebaseFirestore 86 | .collection('notifications') 87 | .where('userId', isEqualTo: userId); 88 | return Right(StreamConverter(query: collection)); 89 | } catch (error) { 90 | print(error); 91 | return Left(NotificationFailure(message: 'Failed to notify')); 92 | } 93 | } 94 | 95 | @override 96 | Future> markAllAsSeen(String userId) async { 97 | try { 98 | final snapshot = await firebaseFirestore 99 | .collection('notifications') 100 | .where('userId', isEqualTo: userId) 101 | .where('isSeen', isEqualTo: false) 102 | .get(); 103 | for (var notification in snapshot.docs) { 104 | await notification.reference.update({'isSeen': true}); 105 | } 106 | return Right(true); 107 | } catch (error) { 108 | print(error); 109 | return Left(NotificationFailure(message: 'Failed to mark as seen')); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/features/profile/representation/pages/profile_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/core/util/config.dart'; 2 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 3 | import 'package:fc_twitter/features/profile/representation/bloc/profile_bloc.dart'; 4 | import 'package:fc_twitter/features/profile/representation/bloc/profile_tabs_bloc.dart'; 5 | import 'package:fc_twitter/features/profile/representation/widgets/user_profile_info.dart'; 6 | import 'package:fc_twitter/features/profile/representation/widgets/user_tab_likes.dart'; 7 | import 'package:fc_twitter/features/profile/representation/widgets/user_tab_media.dart'; 8 | import 'package:fc_twitter/features/profile/representation/widgets/user_tab_replies.dart'; 9 | import 'package:fc_twitter/features/profile/representation/widgets/user_tab_tweets.dart'; 10 | import 'package:fc_twitter/injection_container.dart'; 11 | import 'package:flutter/material.dart'; 12 | import 'package:flutter_bloc/flutter_bloc.dart'; 13 | 14 | class ProfileScreen extends StatelessWidget { 15 | static const String pageId = '/profileScreen'; 16 | @override 17 | Widget build(BuildContext context) { 18 | final theme = Theme.of(context); 19 | final UserProfileEntity userProfile = 20 | ModalRoute.of(context).settings.arguments; 21 | final currentUser = context.select( 22 | (bloc) => bloc.state.userProfile, 23 | ); 24 | 25 | return DefaultTabController( 26 | length: 4, 27 | child: Scaffold( 28 | body: SafeArea( 29 | child: CustomScrollView( 30 | slivers: [ 31 | SliverAppBar( 32 | stretch: true, 33 | pinned: true, 34 | backgroundColor: theme.scaffoldBackgroundColor, 35 | expandedHeight: Config.yMargin(context, 50), 36 | flexibleSpace: FlexibleSpaceBar( 37 | background: Container( 38 | color: theme.scaffoldBackgroundColor, 39 | child: BlocBuilder( 40 | builder: (context, state) { 41 | if (state is FetchingUserProfileComplete) { 42 | return UserProfileInfo( 43 | currentUser: state.userProfile, 44 | displayUser: userProfile, 45 | isCurrentUser: userProfile != null 46 | ? userProfile.id == state.userProfile.id 47 | : true, 48 | isFollowing: userProfile != null 49 | ? state.userProfile.following?.any((element) => 50 | element.path.endsWith(userProfile?.id)) 51 | : false, 52 | ); 53 | } 54 | return SizedBox.expand(); 55 | }), 56 | ), 57 | ), 58 | bottom: TabBar( 59 | indicatorColor: theme.primaryColor, 60 | labelPadding: const EdgeInsets.only(bottom: 10), 61 | labelColor: theme.primaryColor, 62 | unselectedLabelColor: theme.accentColor, 63 | tabs: [ 64 | Text('Tweets'), 65 | Text('Replies'), 66 | Text('Media'), 67 | Text('Likes'), 68 | ], 69 | ), 70 | ), 71 | SliverFillRemaining( 72 | child: BlocProvider( 73 | create: (context) => sl(), 74 | child: TabBarView( 75 | children: [ 76 | UserTabTweets(userProfile: userProfile ?? currentUser), 77 | UserTabReplies(userProfile: userProfile ?? currentUser), 78 | UserTabMedias(userProfile: userProfile ?? currentUser), 79 | UserTabLikes(userProfile: userProfile ?? currentUser), 80 | ], 81 | ), 82 | ), 83 | ), 84 | ], 85 | ), 86 | ), 87 | ), 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/fixtures/fixture_reader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:fc_twitter/features/authentication/data/model/user_model.dart'; 5 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 6 | import 'package:fc_twitter/features/notification/data/model/notification_model.dart'; 7 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 8 | import 'package:fc_twitter/features/profile/data/model/user_profile_model.dart'; 9 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 10 | import 'package:fc_twitter/features/settings/data/model/theme_model.dart'; 11 | import 'package:fc_twitter/features/settings/domain/entity/theme_entity.dart'; 12 | import 'package:fc_twitter/features/tweeting/data/model/tweet_model.dart'; 13 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 14 | 15 | import '../mocks/mocks.dart'; 16 | 17 | final _mockDocumentReference = MockDocumentReference(); 18 | 19 | MockDocumentReference get docReference => _mockDocumentReference; 20 | 21 | Map themeJsonFixture() => { 22 | 'isLight': true, 23 | 'isDim': false, 24 | 'isLightsOut': true, 25 | }; 26 | 27 | ThemeModel themeModelFixture() => ThemeModel( 28 | isLight: true, 29 | isDim: false, 30 | isLightsOut: true, 31 | ); 32 | 33 | ThemeEntity themeEntityFixture() => ThemeEntity( 34 | isLight: true, 35 | isDim: false, 36 | isLightsOut: true, 37 | ); 38 | 39 | UserEntity userEntityFixture() => UserEntity( 40 | email: 'ifeanyi@email.com', 41 | password: '123456', 42 | userName: 'onuoha', 43 | ); 44 | 45 | UserModel userModelFixture() => UserModel( 46 | email: 'ifeanyi@email.com', 47 | password: '123456', 48 | userName: 'onuoha', 49 | ); 50 | 51 | UserProfileModel userProfileModelFixture() => UserProfileModel( 52 | id: '001', 53 | name: 'ifeanyi', 54 | userName: 'onuoha', 55 | location: 'Abuja', 56 | ); 57 | 58 | UserProfileEntity userProfileEntityFixture() => UserProfileEntity( 59 | id: '001', 60 | name: 'ifeanyi', 61 | userName: 'onuoha', 62 | location: 'Abuja', 63 | ); 64 | 65 | String jsonUserProfileFixture() => json.encode({ 66 | "id": "001", 67 | "name": "ifeanyi", 68 | "userName": "onuoha", 69 | "location": "Abuja", 70 | "token": null, 71 | "bio": null, 72 | "website": null, 73 | "dateOfBirth": null, 74 | "dateJoined": null, 75 | "profilePhoto": null, 76 | "coverPhoto": null, 77 | "following": null, 78 | "followers": null 79 | }); 80 | 81 | TweetEntity tweetEntityFixture() => TweetEntity( 82 | id: '001', 83 | userProfile: docReference, 84 | message: 'hello world', 85 | noOfComments: 0, 86 | isComment: false, 87 | isRetweet: false, 88 | hasMedia: false, 89 | timeStamp: Timestamp(0, 0), 90 | ); 91 | 92 | TweetModel tweetModelFixture() => TweetModel( 93 | id: '001', 94 | userId: '001', 95 | userProfile: docReference, 96 | message: 'hello world', 97 | noOfComments: 0, 98 | isComment: false, 99 | isRetweet: false, 100 | hasMedia: false, 101 | timeStamp: Timestamp(0, 0), 102 | ); 103 | 104 | String jsonTweetFixture() => json.encode({ 105 | // "userProfile": json.decode(jsonUserProfileFixture()), 106 | "message": "hello world", 107 | "timeStamp": "0s", 108 | "noOfComments": 0, 109 | }); 110 | 111 | NotificationEntity notificationEntityFixture() => NotificationEntity( 112 | userId: '001', 113 | userProfile: docReference, 114 | tweet: docReference, 115 | isSeen: false, 116 | ); 117 | 118 | NotificationModel notificationModelFixture() => NotificationModel( 119 | userId: '001', 120 | userProfile: docReference, 121 | tweet: docReference, 122 | isSeen: false, 123 | ); 124 | 125 | String jsonNotificationFixture() => json.encode({ 126 | "userId": "001", 127 | "userProfile": docReference, 128 | "tweet": docReference, 129 | "isSeen": false, 130 | }); 131 | -------------------------------------------------------------------------------- /test/features/notification/repository/notification_repository_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/error/failure.dart'; 3 | import 'package:fc_twitter/core/model/stream_converter.dart'; 4 | import 'package:fc_twitter/features/notification/data/repository/notification_repository.dart'; 5 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 6 | import 'package:flutter_test/flutter_test.dart'; 7 | import 'package:http/http.dart'; 8 | import 'package:mockito/mockito.dart'; 9 | 10 | import '../../../fixtures/fixture_reader.dart'; 11 | import '../../../mocks/mocks.dart' as mock; 12 | 13 | void main() { 14 | mock.MockFirebaseFirestore mockFirebaseFirestore; 15 | mock.MockFirebaseMessaging mockFirebaseMessaging; 16 | mock.MockClient mockClient; 17 | mock.MockCollectionReference collectionReference; 18 | mock.MockQuery query; 19 | TweetEntity tweetEntity; 20 | NotificationRepositoryImpl notificationRepositoryImpl; 21 | 22 | setUp(() { 23 | tweetEntity = tweetEntityFixture(); 24 | mockFirebaseFirestore = mock.MockFirebaseFirestore(); 25 | mockFirebaseMessaging = mock.MockFirebaseMessaging(); 26 | mockClient = mock.MockClient(); 27 | collectionReference = mock.MockCollectionReference(); 28 | query = mock.MockQuery(); 29 | notificationRepositoryImpl = NotificationRepositoryImpl( 30 | firebaseFirestore: mockFirebaseFirestore, 31 | firebaseMessaging: mockFirebaseMessaging, 32 | httpClient: mockClient, 33 | ); 34 | }); 35 | 36 | group('notification repository fetchNotifications', () { 37 | test('should return a StreamConverter when successful', () async{ 38 | when(mockFirebaseFirestore.collection(any)).thenReturn(collectionReference); 39 | when(collectionReference.where(any, isEqualTo: anyNamed('isEqualTo'))).thenReturn(query); 40 | 41 | final result = await notificationRepositoryImpl.fetchNotifications('userId'); 42 | 43 | expect(result, Right(StreamConverter())); 44 | }); 45 | 46 | test('should return a NotificationFailure when un-successful', () async{ 47 | when(mockFirebaseFirestore.collection(any)).thenThrow(Error()); 48 | 49 | final result = await notificationRepositoryImpl.fetchNotifications('userId'); 50 | 51 | expect(result, Left(NotificationFailure(message: 'Failed to notify'))); 52 | }); 53 | }); 54 | 55 | group('notification repository SendLikeNotifcation', () { 56 | test('should return true when successful', () async{ 57 | when(mockFirebaseFirestore.collection(any)).thenReturn(collectionReference); 58 | when( 59 | mockClient.post(any, 60 | headers: anyNamed('headers'), body: anyNamed('body')), 61 | ).thenAnswer((_) => Future.value(Response('', 200))); 62 | 63 | final response = await notificationRepositoryImpl.sendLikeNotification(tweetEntity); 64 | 65 | expect(response, Right(true)); 66 | }); 67 | 68 | test('should return NotificationFailure when un-successful', () async{ 69 | when( 70 | mockClient.post(any, 71 | headers: anyNamed('headers'), body: anyNamed('body')), 72 | ).thenAnswer((_) => Future.value(Response('', 500))); 73 | 74 | final response = await notificationRepositoryImpl.sendLikeNotification(tweetEntity); 75 | 76 | expect(response, Left(NotificationFailure(message: 'Failed to notify'))); 77 | }); 78 | }); 79 | 80 | group('notification repository MarkAllAsRead', () { 81 | test('should return true when successful', () async{ 82 | when(mockFirebaseFirestore.collection(any)).thenReturn(collectionReference); 83 | when(collectionReference.where(any, isEqualTo: anyNamed('isEqualTo'))).thenReturn(query); 84 | when(query.where(any, isEqualTo: anyNamed('isEqualTo'))).thenReturn(query); 85 | 86 | final response = await notificationRepositoryImpl.markAllAsSeen('userId'); 87 | 88 | expect(response, Right(true)); 89 | }); 90 | 91 | test('should return NotificationFailure when un-successful', () async{ 92 | when(mockFirebaseFirestore.collection(any)).thenThrow(Error()); 93 | 94 | final response = await notificationRepositoryImpl.markAllAsSeen('userId'); 95 | 96 | expect(response, Left(NotificationFailure(message: 'Failed to mark as seen'))); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /lib/features/notification/representation/widgets/notification_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:fc_twitter/core/util/config.dart'; 2 | import 'package:fc_twitter/features/notification/domain/entity/notification_entity.dart'; 3 | import 'package:fc_twitter/features/profile/data/model/user_profile_model.dart'; 4 | import 'package:fc_twitter/features/profile/domain/entity/user_profile_entity.dart'; 5 | import 'package:fc_twitter/features/profile/representation/widgets/avatar.dart'; 6 | import 'package:fc_twitter/features/tweeting/data/model/tweet_model.dart'; 7 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 8 | import 'package:flutter/material.dart'; 9 | 10 | class NotificationItem extends StatefulWidget { 11 | final NotificationEntity notification; 12 | 13 | NotificationItem({this.notification}); 14 | @override 15 | _NotificationItemState createState() => _NotificationItemState(); 16 | } 17 | 18 | class _NotificationItemState extends State { 19 | Future _getTweet; 20 | Future _getUserProfile; 21 | 22 | @override 23 | void initState() { 24 | super.initState(); 25 | _getTweet = widget.notification.tweet 26 | .get() 27 | .then((value) => TweetModel.fromSnapShot(value)); 28 | _getUserProfile = widget.notification.userProfile 29 | .get() 30 | .then((value) => UserProfileModel.fromDoc(value)); 31 | } 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | final theme = Theme.of(context); 36 | return Column( 37 | children: [ 38 | Container( 39 | width: double.infinity, 40 | padding: const EdgeInsets.all(10), 41 | decoration: BoxDecoration( 42 | color: widget.notification.isSeen 43 | ? theme.scaffoldBackgroundColor 44 | : theme.primaryColor.withOpacity(0.2), 45 | ), 46 | child: FutureBuilder( 47 | future: _getTweet, 48 | builder: (context, snapshot) { 49 | if (snapshot.connectionState == ConnectionState.waiting) { 50 | return SizedBox(height: 20); 51 | } 52 | 53 | final tweet = snapshot.data; 54 | return Row( 55 | crossAxisAlignment: CrossAxisAlignment.start, 56 | children: [ 57 | Padding( 58 | padding: EdgeInsets.only(left: Config.xMargin(context, 4)), 59 | child: Icon(Icons.favorite, color: Colors.red), 60 | ), 61 | SizedBox(width: 10), 62 | FutureBuilder( 63 | future: _getUserProfile, 64 | builder: (context, snapshot) { 65 | if (snapshot.connectionState == ConnectionState.waiting) { 66 | return SizedBox.shrink(); 67 | } 68 | 69 | final profile = snapshot.data; 70 | return Column( 71 | crossAxisAlignment: CrossAxisAlignment.start, 72 | children: [ 73 | Avatar(userProfile: profile, radius: 15), 74 | SizedBox(height: 6), 75 | Row( 76 | children: [ 77 | Text( 78 | profile.name, 79 | style: TextStyle( 80 | fontWeight: FontWeight.w500, 81 | fontSize: Config.xMargin(context, 4.2), 82 | ), 83 | ), 84 | SizedBox(width: 5), 85 | Text('liked your Tweet') 86 | ], 87 | ), 88 | SizedBox(height: 5), 89 | Text( 90 | tweet.message, 91 | style: TextStyle( 92 | color: theme.accentColor, 93 | ), 94 | ), 95 | ], 96 | ); 97 | }, 98 | ) 99 | ], 100 | ); 101 | }, 102 | ), 103 | ), 104 | Divider(thickness: 1, height: 0) 105 | ], 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/features/tweeting/data/model/tweet_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:fc_twitter/features/tweeting/domain/entity/tweet_entity.dart'; 3 | import 'package:flutter/cupertino.dart'; 4 | 5 | class TweetModel extends TweetEntity { 6 | TweetModel( 7 | {@required id, 8 | @required userProfile, 9 | @required message, 10 | @required timeStamp, 11 | userId, 12 | retweetersProfile, 13 | quoteTo, 14 | noOfComments, 15 | commentTo, 16 | retweetTo, 17 | images, 18 | retweetedBy, 19 | likedBy, 20 | quotedBy, 21 | isQuote, 22 | isRetweet, 23 | hasMedia, 24 | isComment}) 25 | : super( 26 | id: id, 27 | userId: userId, 28 | userProfile: userProfile, 29 | retweetersProfile: retweetersProfile, 30 | message: message, 31 | timeStamp: timeStamp, 32 | quoteTo: quoteTo, 33 | retweetTo: retweetTo, 34 | commentTo: commentTo, 35 | noOfComments: noOfComments, 36 | images: images, 37 | retweetedBy: retweetedBy, 38 | likedBy: likedBy, 39 | quotedBy: quotedBy, 40 | isQuote: isQuote, 41 | isRetweet: isRetweet, 42 | hasMedia: hasMedia, 43 | isComment: isComment, 44 | ); 45 | 46 | factory TweetModel.fromSnapShot(DocumentSnapshot snapShot) { 47 | final data = snapShot.data(); 48 | return TweetModel( 49 | id: snapShot.id, 50 | userId: data['userId'], 51 | userProfile: data['userProfile'], 52 | message: data['message'], 53 | timeStamp: data['timeStamp'], 54 | quoteTo: data['quoteTo'], 55 | retweetersProfile: data['retweetersProfile'], 56 | retweetTo: data['retweetTo'], 57 | commentTo: data['commentTo'], 58 | noOfComments: data['noOfComments'] ?? 0, 59 | images: data['images'] ?? List(), 60 | retweetedBy: data['retweetedBy'] ?? List(), 61 | quotedBy: data['quotedBy'] ?? List(), 62 | likedBy: data['likedBy'] ?? List(), 63 | isQuote: data['isQuote'] ?? false, 64 | hasMedia: data['hasMedia'] ?? false, 65 | isRetweet: data['isRetweet'] ?? false, 66 | isComment: data['isComment'] ?? false, 67 | ); 68 | } 69 | 70 | factory TweetModel.fromEntity(TweetEntity tweet) { 71 | return TweetModel( 72 | id: tweet.id, 73 | userId: tweet.userId, 74 | userProfile: tweet.userProfile, 75 | retweetersProfile: tweet.retweetersProfile, 76 | message: tweet.message, 77 | timeStamp: tweet.timeStamp, 78 | retweetTo: tweet.retweetTo, 79 | quoteTo: tweet.quoteTo, 80 | commentTo: tweet.commentTo, 81 | noOfComments: tweet.noOfComments, 82 | images: tweet.images, 83 | retweetedBy: tweet.retweetedBy, 84 | quotedBy: tweet.quotedBy, 85 | likedBy: tweet.likedBy, 86 | isQuote: tweet.isQuote, 87 | hasMedia: tweet.hasMedia, 88 | isRetweet: tweet.isRetweet, 89 | isComment: tweet.isComment, 90 | ); 91 | } 92 | 93 | TweetEntity toEntity() { 94 | return TweetEntity( 95 | id: this.id, 96 | userId: this.userId, 97 | userProfile: this.userProfile, 98 | retweetersProfile: this.retweetersProfile, 99 | message: this.message, 100 | timeStamp: this.timeStamp, 101 | quoteTo: this.quoteTo, 102 | retweetTo: this.retweetTo, 103 | commentTo: this.commentTo, 104 | noOfComments: this.noOfComments, 105 | images: this.images, 106 | retweetedBy: this.retweetedBy, 107 | quotedBy: this.quotedBy, 108 | likedBy: this.likedBy, 109 | isQuote: this.isQuote, 110 | hasMedia: this.hasMedia, 111 | isRetweet: this.isRetweet, 112 | isComment: this.isComment, 113 | ); 114 | } 115 | 116 | Map toMap() { 117 | return { 118 | 'id': this.id, 119 | 'userId': this.userId, 120 | 'userProfile': this.userProfile, 121 | 'retweetersProfile': this.retweetersProfile, 122 | 'message': this.message, 123 | 'timeStamp': this.timeStamp, 124 | 'quoteTo': this.quoteTo, 125 | 'commentTo': this.commentTo, 126 | 'retweetTo': this.retweetTo, 127 | 'noOfComments': this.noOfComments, 128 | 'images': this.images, 129 | 'retweetedBy': this.retweetedBy, 130 | 'quotedBy': this.quotedBy, 131 | 'likedBy': this.likedBy, 132 | 'isQuote': this.isQuote, 133 | 'hasMedia': this.hasMedia, 134 | 'isRetweet': this.isRetweet, 135 | 'isComment': this.isComment, 136 | }; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /test/features/authentication/bloc/auth_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:fc_twitter/core/error/failure.dart'; 3 | import 'package:fc_twitter/features/authentication/domain/user_entity/user_entity.dart'; 4 | import 'package:fc_twitter/features/authentication/representation/bloc/bloc.dart'; 5 | import 'package:flutter_test/flutter_test.dart'; 6 | import 'package:mockito/mockito.dart'; 7 | 8 | import '../../../fixtures/fixture_reader.dart'; 9 | import '../../../mocks/mocks.dart'; 10 | 11 | void main() { 12 | UserEntity userEntity; 13 | AuthBloc authBloc; 14 | MockUserCredential mockUserCredential; 15 | MockUserRepository mockUserRepository; 16 | MockFireBaseUser fireBaseUser; 17 | 18 | setUp(() { 19 | userEntity = userEntityFixture(); 20 | fireBaseUser = MockFireBaseUser(); 21 | mockUserCredential = MockUserCredential(); 22 | mockUserRepository = MockUserRepository(); 23 | authBloc = AuthBloc( 24 | initialState: InitialAuthState(), 25 | userRepository: mockUserRepository, 26 | ); 27 | }); 28 | 29 | group('Auth bloc Sign up event', () { 30 | test( 31 | 'should emit [Authinprogress] and [Authcomplete] when sign up and save is successful', 32 | () async { 33 | when(mockUserRepository.signUpNewUser(any)).thenAnswer( 34 | (_) => Future.value(Right(mockUserCredential)), 35 | ); 36 | when(mockUserCredential.user).thenReturn(fireBaseUser); 37 | when(fireBaseUser.uid).thenReturn('userId'); 38 | when(mockUserRepository.saveUserDetail(any)) 39 | .thenAnswer( 40 | (_) => Future.value(Right(true)), 41 | ); 42 | final expected = [ 43 | AuthInProgress(), 44 | AuthComplete(), 45 | ]; 46 | expectLater(authBloc, emitsInOrder(expected)); 47 | 48 | authBloc.add(SignUp(user: userEntity)); 49 | }); 50 | 51 | test('should emit [Authinprogress] and [Authfailed] when sign up fails', 52 | () async { 53 | when(mockUserRepository.signUpNewUser(any)).thenAnswer( 54 | (_) => Future.value(Left(AuthFailure(message: 'Sign up failed'))), 55 | ); 56 | final expected = [ 57 | AuthInProgress(), 58 | AuthFailed(message: 'Sign up failed'), 59 | ]; 60 | expectLater(authBloc, emitsInOrder(expected)); 61 | 62 | authBloc.add(SignUp(user: userEntity)); 63 | }); 64 | 65 | test('should emit [AuthFailed] when user details fails to save', () async { 66 | when(mockUserRepository.signUpNewUser(any)).thenAnswer( 67 | (_) => Future.value(Right(mockUserCredential)), 68 | ); 69 | when(mockUserCredential.user).thenReturn(fireBaseUser); 70 | when(fireBaseUser.uid).thenReturn('userId'); 71 | when(mockUserRepository.saveUserDetail(any)) 72 | .thenAnswer( 73 | (_) => 74 | Future.value(Left(AuthFailure(message: 'Saving details failed'))), 75 | ); 76 | final expected = [ 77 | AuthInProgress(), 78 | AuthFailed(message: 'Saving details failed'), 79 | ]; 80 | expectLater(authBloc, emitsInOrder(expected)); 81 | 82 | authBloc.add(SignUp(user: userEntity)); 83 | }); 84 | }); 85 | 86 | group('Auth bloc Log in event', () { 87 | test( 88 | 'should emit [Authinprogress] and [Authcomplete] when log in is successful', 89 | () async { 90 | when(mockUserRepository.logInUser(any)).thenAnswer( 91 | (_) => Future.value(Right(mockUserCredential)), 92 | ); 93 | final expected = [ 94 | AuthInProgress(), 95 | AuthComplete(), 96 | ]; 97 | expectLater(authBloc, emitsInOrder(expected)); 98 | 99 | authBloc.add(Login(user: userEntity)); 100 | }); 101 | 102 | test('should emit [Authinprogress] and [Authfailed] when log in fails', 103 | () async { 104 | when(mockUserRepository.logInUser(any)).thenAnswer( 105 | (_) => Future.value(Left(AuthFailure(message: 'Sign up failed'))), 106 | ); 107 | final expected = [ 108 | AuthInProgress(), 109 | AuthFailed(message: 'Sign up failed'), 110 | ]; 111 | expectLater(authBloc, emitsInOrder(expected)); 112 | 113 | authBloc.add(Login(user: userEntity)); 114 | }); 115 | }); 116 | 117 | group('others', () { 118 | test(('confirm inistial bloc state'), () { 119 | expect(authBloc.state, equals(InitialAuthState())); 120 | }); 121 | 122 | test(('confirm log out is called for log out event'), () async { 123 | authBloc.add(LogOut()); 124 | await untilCalled(mockUserRepository.logOutUser()); 125 | verify(mockUserRepository.logOutUser()); 126 | }); 127 | }); 128 | } 129 | --------------------------------------------------------------------------------