diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6b9372e..64245a7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,3 +21,7 @@ - [ ] ✅ Build configuration change - [ ] 📝 Documentation - [ ] 🗑️ Chore + + +## Test Changes on Device +You can download your APK from Firebase App Distribution once this PR builds successfully via the following link: https://appdistribution.firebase.dev/i/c796669942f8a811 \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1466074..650a1dd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Generate production APK +name: Generate Dev APK on: push: @@ -36,28 +36,30 @@ jobs: - name: 🕵️ Analyze run: flutter analyze lib - # - name: ⚙️ Download Android keystore - # id: android_keystore - # uses: timheuer/base64-to-file@v1.2 - # with: - # fileName: key.jks - # encodedString: ${{ secrets.RELEASE_KEYSTORE }} + - name: ⚙️ Download Android keystore + id: android_keystore + uses: timheuer/base64-to-file@v1.2 + with: + fileName: key.jks + encodedString: ${{ secrets.DEV_KEYSTORE }} - # - name: 🔐 Create key.properties - # run: | - # echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties - # echo "storePassword=${{ secrets.RELEASE_KEYSTORE_PASSPHRASE }}" >> android/key.properties - # echo "keyPassword=${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" >> android/key.properties - # echo "keyAlias=${{ secrets.RELEASE_KEYSTORE_ALIAS }}" >> android/key.properties + - name: 🔐 Create key.properties + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties + echo "storePassword=${{ secrets.DEV_KEYSTORE_PASSPHRASE }}" >> android/key.properties + echo "keyPassword=${{ secrets.DEV_KEYSTORE_PASSWORD }}" >> android/key.properties + echo "keyAlias=${{ secrets.DEV_KEYSTORE_ALIAS }}" >> android/key.properties - - name: ⚙️ Build AAB - run: flutter build apk --flavor production --target lib/main_production.dart + - name: ⚙️ Build APK + run: flutter build apk --flavor production --target lib/main_production.dart - - name: 📦 Archive - uses: actions/upload-artifact@v4 + - name: 📦 Upload artifact to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 with: - name: production-apk - path: build/app/outputs/flutter-apk/app-production-release.apk + appId: ${{secrets.FIREBASE_APP_ID}} + serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_CREDENTIALS }} + groups: internal-testing + file: build/app/outputs/flutter-apk/app-production-release.apk # - name: ⚙️ Setup Ruby # uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 3b03ed0..7d42c60 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -36,3 +36,28 @@ jobs: - name: 🕵️ Analyze run: flutter analyze lib + - name: ⚙️ Download Android keystore + id: android_keystore + uses: timheuer/base64-to-file@v1.2 + with: + fileName: key.jks + encodedString: ${{ secrets.DEV_KEYSTORE }} + + - name: 🔐 Create key.properties + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties + echo "storePassword=${{ secrets.DEV_KEYSTORE_PASSPHRASE }}" >> android/key.properties + echo "keyPassword=${{ secrets.DEV_KEYSTORE_PASSWORD }}" >> android/key.properties + echo "keyAlias=${{ secrets.DEV_KEYSTORE_ALIAS }}" >> android/key.properties + + - name: ⚙️ Build APK + run: flutter build apk --flavor production --target lib/main_production.dart + + - name: Upload artifact to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{secrets.FIREBASE_APP_ID}} + serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_CREDENTIALS }} + groups: internal-testing + file: build/app/outputs/flutter-apk/app-production-release.apk + diff --git a/README.md b/README.md index 5415938..0c4f392 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,18 @@ For much smaller fixes like typos, you can skip the create issue step. Tip: Keep feature contributions small and focused. This makes it easy to review contributions and spot errors if any +## APK Signing +To ensure that the correct SHA1 key is available for signing the APK to enable social auth with Firebase, we need to maintain a single public keystore so that we don't need to add everyone's debug key to the Firebase app. + +Create a file `android/key.properties` with values as follows +```jks +storePassword=publicDevKey@2024 +keyPassword=publicDevKey@2024 +keyAlias=publicDevKey +storeFile=../public-dev-keystore.jks +``` +Ensure the `storeFile` path is correct depending on your OS + ## App Architecture ### State Management diff --git a/android/.gitignore b/android/.gitignore index d6aa291..47835f3 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -9,3 +9,4 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +!public-dev-keystore.jks diff --git a/android/app/build.gradle b/android/app/build.gradle index 13e0d44..4379e5c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -51,11 +51,8 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "dev.flutterconke.fluttercon" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -100,12 +97,12 @@ android { buildTypes { release { - signingConfig signingConfigs.debug + signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt') } debug { - signingConfig signingConfigs.debug + signingConfig signingConfigs.release } } } diff --git a/android/public-dev-keystore.jks b/android/public-dev-keystore.jks new file mode 100644 index 0000000..2991de3 Binary files /dev/null and b/android/public-dev-keystore.jks differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 85e1573..8549de7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -131,7 +131,6 @@ 9B9CA4492211BE3FBBB094D3 /* Pods-RunnerTests.profile-staging.xcconfig */, 72B948CA6CD16EA98796ACB5 /* Pods-RunnerTests.profile-development.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -226,7 +225,8 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 14E3FE9DEF97E062EC966A08 /* [CP] Embed Pods Frameworks */, - FBB65C31CB7BD1C328618252 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, + 854873C37B2731981A103088 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, + E33879E44333D33EE3240A50 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -354,6 +354,24 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 854873C37B2731981A103088 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n"; + }; 918393D15F920ACF475A28B1 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -391,23 +409,22 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - FBB65C31CB7BD1C328618252 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { + E33879E44333D33EE3240A50 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); - name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -524,6 +541,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = Fluttercon; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -618,6 +636,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = Fluttercon; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -707,6 +726,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = Fluttercon; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -801,6 +821,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = "[DEV] Fluttercon"; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -893,6 +914,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = "[DEV] Fluttercon"; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -984,6 +1006,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = "[DEV] Fluttercon"; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1078,6 +1101,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = "[STG] Fluttercon"; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1172,6 +1196,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = "[STG] Fluttercon"; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1261,6 +1286,7 @@ ENABLE_BITCODE = NO; FLAVOR_APP_NAME = "[STG] Fluttercon"; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 1a08330..ddd3570 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,58 +1,71 @@ - - CFBundleLocalizations - - en - es - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - $(FLAVOR_APP_NAME) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(FLAVOR_APP_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - UIStatusBarHidden - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(FLAVOR_APP_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + es + + CFBundleName + $(FLAVOR_APP_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.602867001820-p21jqotknhl6gcinm7lv9tmubhv30gh9 + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + GIDClientID + 602867001820-p21jqotknhl6gcinm7lv9tmubhv30gh9.apps.googleusercontent.com + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + diff --git a/lib/app.dart b/lib/app.dart index 346e9f9..c82aaba 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttercon/common/repository/hive_repository.dart'; +import 'package:fluttercon/common/utils/router.dart'; import 'package:fluttercon/core/di/injectable.dart'; -import 'package:fluttercon/core/local_storage.dart'; -import 'package:fluttercon/core/navigator/main_navigator.dart'; import 'package:fluttercon/core/theme/bloc/theme_bloc.dart'; import 'package:fluttercon/core/theme/theme_data.dart'; import 'package:fluttercon/l10n/l10n.dart'; @@ -16,30 +16,22 @@ class MyApp extends StatefulWidget { } class MyAppState extends State { - final navigatorKey = MainNavigatorState.navigationKey; - NavigatorState get navigator => - MainNavigatorState.navigationKey.currentState!; - @override Widget build(BuildContext context) { - final localStorage = getIt(); - return BlocProvider( create: (context) => ThemeBloc(), child: BlocBuilder( builder: (context, themeMode) { return Sizer( builder: (context, orientation, deviceType) { - return MaterialApp( - themeMode: localStorage.getThemeMode(), + return MaterialApp.router( + themeMode: getIt().retrieveThemeMode(), theme: AppTheme.lightTheme(), darkTheme: AppTheme.darkTheme(), debugShowCheckedModeBanner: false, - navigatorKey: navigatorKey, - initialRoute: MainNavigatorState.initialRoute, - onGenerateRoute: MainNavigatorState.onGenerateRoute, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + routerConfig: FlutterConRouter.router, ); }, ); diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index df286ec..b2ccf83 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -1,11 +1,15 @@ import 'dart:async'; import 'dart:developer'; -import 'package:bloc/bloc.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttercon/common/repository/hive_repository.dart'; +import 'package:fluttercon/core/di/injectable.dart'; +import 'package:fluttercon/features/auth/cubit/google_sign_in_cubit.dart'; +import 'package:fluttercon/features/auth/cubit/social_auth_sign_in_cubit.dart'; import 'package:fluttercon/firebase_options.dart'; class AppBlocObserver extends BlocObserver { @@ -32,7 +36,28 @@ Future bootstrap(FutureOr Function() builder) async { options: DefaultFirebaseOptions.currentPlatform, ); - runApp(await builder()); + await configureDependencies(); + await getIt().initBoxes(); + + runApp( + MultiBlocProvider( + // Register all the BLoCs here + providers: [ + BlocProvider( + create: (_) => GoogleSignInCubit( + authRepository: getIt(), + ), + ), + BlocProvider( + create: (_) => SocialAuthSignInCubit( + authRepository: getIt(), + hiveRepository: getIt(), + ), + ), + ], + child: await builder(), + ), + ); }, (error, stackTrace) { if (kDebugMode) { log(error.toString(), stackTrace: stackTrace); diff --git a/lib/common/data/models/adapters.dart b/lib/common/data/models/adapters.dart new file mode 100644 index 0000000..546ef41 --- /dev/null +++ b/lib/common/data/models/adapters.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import 'package:fluttercon/common/data/models/models.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +class FlutterConUserAdapter extends TypeAdapter { + @override + final typeId = 0; + + @override + FlutterConUser read(BinaryReader reader) { + return FlutterConUser.fromJson( + Map.of( + json.decode(reader.read() as String) as Map, + ), + ); + } + + @override + void write(BinaryWriter writer, FlutterConUser obj) { + writer.write(json.encode(obj.toJson())); + } +} diff --git a/lib/common/data/models/auth.dart b/lib/common/data/models/auth.dart new file mode 100644 index 0000000..f94e714 --- /dev/null +++ b/lib/common/data/models/auth.dart @@ -0,0 +1,28 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'auth.freezed.dart'; +part 'auth.g.dart'; + +@freezed +class FlutterConUser with _$FlutterConUser { + const factory FlutterConUser( + String name, + String email, + String avatar, + @JsonKey(name: 'created_at') String createdAt, + ) = _FlutterConUser; + + factory FlutterConUser.fromJson(Map json) => + _$FlutterConUserFromJson(json); +} + +@freezed +class AuthResult with _$AuthResult { + const factory AuthResult({ + required String token, + required FlutterConUser user, + }) = _AuthResult; + + factory AuthResult.fromJson(Map json) => + _$AuthResultFromJson(json); +} diff --git a/lib/common/data/models/failure.dart b/lib/common/data/models/failure.dart new file mode 100644 index 0000000..81493fc --- /dev/null +++ b/lib/common/data/models/failure.dart @@ -0,0 +1,9 @@ +class Failure implements Exception { + Failure({ + required this.message, + this.statusCode, + }); + + final String message; + final int? statusCode; +} diff --git a/lib/common/data/models/models.dart b/lib/common/data/models/models.dart index a6c22dd..de6c5b5 100644 --- a/lib/common/data/models/models.dart +++ b/lib/common/data/models/models.dart @@ -1,3 +1,5 @@ +export 'auth.dart'; +export 'failure.dart'; export 'room.dart'; export 'session.dart'; export 'speaker.dart'; diff --git a/lib/common/network/api_result.dart b/lib/common/network/api_result.dart deleted file mode 100644 index 8aca7f4..0000000 --- a/lib/common/network/api_result.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'api_result.freezed.dart'; - -@freezed -class ApiResult with _$ApiResult { - const factory ApiResult.success(T data) = Success; - - const factory ApiResult.failure(String error) = Failure; -} diff --git a/lib/common/network/dio_client.dart b/lib/common/network/dio_client.dart deleted file mode 100755 index 8e7afec..0000000 --- a/lib/common/network/dio_client.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:dio/dio.dart' show Dio, ResponseType; -import 'package:fluttercon/common/network/dio_interceptor.dart'; -import 'package:fluttercon/common/utils/constants/api_constants.dart'; -import 'package:fluttercon/common/utils/env/flavor_config.dart'; -import 'package:injectable/injectable.dart'; - -@lazySingleton -class DioClient { - DioClient(this.dio) { - dio - ..options.baseUrl = FlutterConConfig.instance!.values.baseUrl - ..options.headers = ApiConstants.headers - ..options.connectTimeout = ApiConstants.connectionTimeout - ..options.receiveTimeout = ApiConstants.receiveTimeout - ..options.responseType = ResponseType.json - ..interceptors.add(DioInterceptor()); - } - final Dio dio; -} diff --git a/lib/common/network/dio_exception.dart b/lib/common/network/dio_exception.dart deleted file mode 100755 index 1657340..0000000 --- a/lib/common/network/dio_exception.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:dio/dio.dart' show DioException, DioExceptionType; - -import 'package:fluttercon/common/utils/constants/api_constants.dart'; - -class DioExceptions implements Exception { - DioExceptions.fromDioError(DioException dioException) { - message = switch (dioException.type) { - DioExceptionType.cancel => ApiConstants.cancelRequest, - DioExceptionType.connectionTimeout => ApiConstants.connectionTimeOut, - DioExceptionType.receiveTimeout => ApiConstants.receiveTimeOut, - DioExceptionType.badResponse => _handleError( - dioException.response?.statusCode, - dioException.response?.data, - ), - DioExceptionType.sendTimeout => ApiConstants.sendTimeOut, - DioExceptionType.connectionError => ApiConstants.socketException, - DioExceptionType.badCertificate || - DioExceptionType.unknown => - ApiConstants.unknownError, - }; - } - - late String message; - - String _handleError(int? statusCode, dynamic error) { - return switch (statusCode) { - 400 => ApiConstants.badRequest, - 401 => ApiConstants.unauthorized, - 403 => ApiConstants.forbidden, - 404 => ApiConstants.notFound, - 422 => ApiConstants.duplicateEmail, - 500 => ApiConstants.internalServerError, - 502 => ApiConstants.badGateway, - _ => ApiConstants.unknownError - }; - } - - @override - String toString() => message; -} diff --git a/lib/common/network/dio_interceptor.dart b/lib/common/network/dio_interceptor.dart deleted file mode 100755 index ce23676..0000000 --- a/lib/common/network/dio_interceptor.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:fluttercon/common/utils/logger.dart'; -import 'package:fluttercon/core/app_extension.dart'; - -class DioInterceptor extends Interceptor { - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - final RequestOptions(:baseUrl, :path, :queryParameters) = options; - logger('====================START===================='); - logger('HTTP method => ${options.method} '); - logger( - 'Request => $baseUrl$path${queryParameters.format}', - ); - logger('Header => ${options.headers}'); - return super.onRequest(options, handler); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) { - final options = err.requestOptions; - logger(options.method); // Debug log - logger('Error: ${err.error}, Message: ${err.message}'); // Error log - return super.onError(err, handler); - } - - @override - void onResponse( - Response response, - ResponseInterceptorHandler handler, - ) { - logger('Response => StatusCode: ${response.statusCode}'); // Debug log - logger('Response => Body: ${response.data}'); // Debug log - return super.onResponse(response, handler); - } -} diff --git a/lib/common/repository/api_repository.dart b/lib/common/repository/api_repository.dart new file mode 100644 index 0000000..45addb6 --- /dev/null +++ b/lib/common/repository/api_repository.dart @@ -0,0 +1,44 @@ +import 'package:fluttercon/common/data/models/models.dart'; +import 'package:fluttercon/common/utils/network.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class ApiRepository { + final _networkUtil = NetworkUtil(); + + Future> fetchSpeakers({ + required String event, + int perPage = 15, + int page = 1, + }) async { + final response = await _networkUtil.getReq( + '/events/$event/speakers', + queryParameters: {'per_page': perPage, 'page': page}, + ); + + return SpeakerResponse.fromJson(response).data; + } + + Future> fetchRooms({ + required String event, + }) async { + final response = await _networkUtil.getReq( + '/events/$event/rooms', + ); + + return RoomResponse.fromJson(response).data; + } + + Future> fetchSessions({ + required String event, + int perPage = 20, + int page = 1, + }) async { + final response = await _networkUtil.getReq( + '/events/$event/sessions', + queryParameters: {'per_page': perPage, 'page': page}, + ); + + return SessionResponse.fromJson(response).data; + } +} diff --git a/lib/common/repository/auth_repository.dart b/lib/common/repository/auth_repository.dart new file mode 100644 index 0000000..ab92c7e --- /dev/null +++ b/lib/common/repository/auth_repository.dart @@ -0,0 +1,63 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:fluttercon/common/data/models/models.dart'; +import 'package:fluttercon/common/utils/network.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:injectable/injectable.dart'; +import 'package:logger/logger.dart'; + +@singleton +class AuthRepository { + final _networkUtil = NetworkUtil(); + + final FirebaseAuth _auth = FirebaseAuth.instance; + + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: [ + 'profile', + 'email', + ], + ); + + Future signInWithGoogle() async { + try { + final googleSignInAccount = await _googleSignIn.signIn(); + final googleSignInAuthentication = + await googleSignInAccount?.authentication; + + final AuthCredential credential = GoogleAuthProvider.credential( + idToken: googleSignInAuthentication?.idToken, + accessToken: googleSignInAuthentication?.accessToken, + ); + + final authResult = await _auth.signInWithCredential(credential); + + final user = authResult.user; + + if (user != null) { + assert(!user.isAnonymous, 'User must not be anonymous'); + return Future.value(authResult.credential?.accessToken); + } else { + throw Failure(message: 'An unexpected error occured'); + } + } catch (e) { + rethrow; + } + } + + Future signIn({required String token}) async { + try { + final response = await _networkUtil.postWithFormData( + '/social_login/google', + body: { + 'access_token': token, + }, + ); + + Logger().d(response); + + return AuthResult.fromJson(response); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/common/repository/hive_repository.dart b/lib/common/repository/hive_repository.dart new file mode 100644 index 0000000..d0ddd25 --- /dev/null +++ b/lib/common/repository/hive_repository.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/common/data/models/adapters.dart'; +import 'package:fluttercon/common/data/models/auth.dart'; +import 'package:fluttercon/common/utils/env/flavor_config.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:injectable/injectable.dart'; + +@singleton +class HiveRepository { + Future initBoxes() async { + await Hive.initFlutter(); + + Hive.registerAdapter(FlutterConUserAdapter()); + + await Hive.openBox(FlutterConConfig.instance!.values.hiveBox); + } + + void clearPrefs() { + Hive.box(FlutterConConfig.instance!.values.hiveBox) + .deleteAll([ + 'accessToken', + 'profile', + ]); + } + + void clearBox() { + Hive.box(FlutterConConfig.instance!.values.hiveBox).clear(); + } + + void persistToken(String token) { + Hive.box(FlutterConConfig.instance!.values.hiveBox) + .put('accessToken', token); + } + + String? retrieveToken() { + return Hive.box(FlutterConConfig.instance!.values.hiveBox) + .get('accessToken') as String?; + } + + void persistUser(FlutterConUser user) { + Hive.box(FlutterConConfig.instance!.values.hiveBox) + .put('profile', user); + } + + FlutterConUser? retrieveUser() { + return Hive.box(FlutterConConfig.instance!.values.hiveBox) + .get('profile') as FlutterConUser?; + } + + void persistThemeMode(ThemeMode themeMode) { + Hive.box(FlutterConConfig.instance!.values.hiveBox) + .put('themeMode', themeMode.toString()); + } + + ThemeMode retrieveThemeMode() { + final themeMode = + Hive.box(FlutterConConfig.instance!.values.hiveBox) + .get('themeMode') as String?; + + if (themeMode == null) { + return ThemeMode.light; + } + + return ThemeMode.values.firstWhere( + (element) => element.toString() == themeMode, + orElse: () => ThemeMode.system, + ); + } +} diff --git a/lib/common/repository/repository.dart b/lib/common/repository/repository.dart deleted file mode 100644 index d86bee2..0000000 --- a/lib/common/repository/repository.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:dio/dio.dart'; - -import 'package:fluttercon/common/data/models/models.dart'; - -class Repository { - Repository({required this.dio}); - - final Dio dio; - - Future> fetchSpeakers({ - required String event, - int perPage = 15, - int page = 1, - }) async { - final response = await dio.get>( - '/events/$event/speakers', - queryParameters: {'per_page': perPage, 'page': page}, - ); - - if (response.statusCode != 200) { - throw Exception({ - 'statusCode': response.statusCode, - 'body': response.statusMessage, - }); - } - - if (response.data == null) { - throw Exception({ - 'statusCode': response.statusCode, - 'body': 'Data is null', - }); - } - - return SpeakerResponse.fromJson(response.data!).data; - } - - Future> fetchRooms({ - required String event, - }) async { - final response = await dio.get>( - '/events/$event/rooms', - ); - - if (response.statusCode != 200) { - throw Exception({ - 'statusCode': response.statusCode, - 'body': response.statusMessage, - }); - } - - if (response.data == null) { - throw Exception({ - 'statusCode': response.statusCode, - 'body': 'Data is null', - }); - } - - return RoomResponse.fromJson(response.data!).data; - } - - Future> fetchSessions({ - required String event, - int perPage = 20, - int page = 1, - }) async { - final response = await dio.get>( - '/events/$event/sessions', - queryParameters: {'per_page': perPage, 'page': page}, - ); - - if (response.statusCode != 200) { - throw Exception({ - 'statusCode': response.statusCode, - 'body': response.statusMessage, - }); - } - - if (response.data == null) { - throw Exception({ - 'statusCode': response.statusCode, - 'body': 'Data is null', - }); - } - - return SessionResponse.fromJson(response.data!).data; - } -} diff --git a/lib/common/utils/env/flavor_config.dart b/lib/common/utils/env/flavor_config.dart index ee66b49..b834faf 100644 --- a/lib/common/utils/env/flavor_config.dart +++ b/lib/common/utils/env/flavor_config.dart @@ -2,10 +2,12 @@ class FlutterConValues { FlutterConValues({ required this.urlScheme, required this.baseDomain, + required this.hiveBox, }); final String urlScheme; final String baseDomain; + final String hiveBox; String get baseUrl => '$urlScheme://$baseDomain'; } diff --git a/lib/common/utils/network.dart b/lib/common/utils/network.dart new file mode 100644 index 0000000..8c5dbd5 --- /dev/null +++ b/lib/common/utils/network.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter/foundation.dart'; +import 'package:fluttercon/common/data/models/models.dart'; +import 'package:fluttercon/common/repository/hive_repository.dart'; +import 'package:fluttercon/common/utils/env/flavor_config.dart'; +import 'package:fluttercon/core/di/injectable.dart'; +import 'package:logger/logger.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; + +class NetworkUtil { + factory NetworkUtil() => _networkUtil; + + NetworkUtil._internal(); + + static final NetworkUtil _networkUtil = NetworkUtil._internal(); + + final _logger = Logger(); + + Dio _getHttpClient() { + final dio = Dio( + BaseOptions( + baseUrl: '${FlutterConConfig.instance!.values.baseUrl}/v1', + contentType: 'application/json', + headers: { + 'Accept': 'application/json', + 'Api-Authorization-Key': 'droidconKe-2020', + }, + connectTimeout: const Duration(seconds: 60 * 1000), + receiveTimeout: const Duration(seconds: 60 * 1000), + ), + ); + + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + options.headers['Authorization'] = + 'Bearer ${getIt().retrieveToken() ?? ''}'; + return handler.next(options); + }, + ), + ); + + if (kDebugMode) { + dio.interceptors.add( + PrettyDioLogger( + requestHeader: true, + requestBody: true, + ), + ); + } + + (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = + () => HttpClient()..badCertificateCallback = (_, __, ___) => true; + return dio; + } + + Future> getReq( + String url, { + Map? queryParameters, + }) async { + try { + final response = await _getHttpClient().get( + url, + queryParameters: queryParameters, + ); + + final responseBody = response.data as Map; + + if (responseBody.isEmpty) { + throw Failure(message: 'An error occured, please try again later'); + } + + return responseBody; + } on SocketException catch (_) { + throw Failure(message: 'No internet connection'); + } on TimeoutException catch (_) { + throw Failure(message: 'Session timeout'); + } on DioException catch (err) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + + if (err.response?.statusCode == 401) { + throw Failure( + message: 'Session timeout', + ); + } + + if (err.response?.statusCode == 404) { + throw Failure( + message: 'Not found', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 500) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (DioExceptionType.unknown == err.type) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + throw Exception('Server error'); + } else if (DioExceptionType.connectionTimeout == err.type) { + throw const SocketException('No internet connection'); + } else if (DioExceptionType.connectionError == err.type) { + throw const SocketException('No Internet Connection'); + } + throw Exception('Server error'); + } + } + + Future> postReq( + String url, { + Map? body, + Map? queryParameters, + }) async { + try { + final response = await _getHttpClient().post( + url, + data: json.encode(body), + queryParameters: queryParameters, + ); + + final responseBody = response.data as Map; + + Logger().i(responseBody); + + if (responseBody.isEmpty) { + throw Failure(message: 'An error occured, please try again later'); + } + + return responseBody; + } on SocketException catch (_) { + throw Failure(message: 'No internet connection'); + } on TimeoutException catch (_) { + throw Failure(message: 'Session timeout'); + } on DioException catch (err) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + + if (err.response?.statusCode == 401) { + throw Failure( + message: 'Session timeout', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 404) { + throw Failure( + message: 'Not found', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 422) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 500) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (DioExceptionType.unknown == err.type) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + throw Exception('Server error'); + } else if (DioExceptionType.connectionTimeout == err.type) { + throw const SocketException('No internet connection'); + } else if (DioExceptionType.connectionError == err.type) { + throw const SocketException('No Internet Connection'); + } + throw Exception('Server error'); + } + } + + Future> putReq( + String url, { + Map? body, + Map? queryParameters, + }) async { + try { + final response = await _getHttpClient().put( + url, + data: json.encode(body), + queryParameters: queryParameters, + ); + + final responseBody = response.data as Map; + + Logger().i(responseBody); + + if (responseBody.isEmpty) { + throw Failure(message: 'An error occured, please try again later'); + } + + return responseBody; + } on SocketException catch (_) { + throw Failure(message: 'No internet connection'); + } on TimeoutException catch (_) { + throw Failure(message: 'Session timeout'); + } on DioException catch (err) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + + if (err.response?.statusCode == 401) { + throw Failure( + message: 'Session timeout', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 404) { + throw Failure( + message: 'Not found', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 422) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 500) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (DioExceptionType.unknown == err.type) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + throw Exception('Server error'); + } else if (DioExceptionType.connectionTimeout == err.type) { + throw const SocketException('No internet connection'); + } else if (DioExceptionType.connectionError == err.type) { + throw const SocketException('No Internet Connection'); + } + throw Exception('Server error'); + } + } + + Future> postWithFormData( + String url, { + Map? body, + Map? queryParameters, + String? filePath, + String? field, + }) async { + try { + final response = await _getHttpClient().post( + url, + data: FormData.fromMap({ + if (field != null && filePath != null) + field: await MultipartFile.fromFile(filePath), + ...?body, + }), + queryParameters: queryParameters, + ); + + final responseBody = response.data as Map; + + Logger().i(responseBody); + + if (responseBody.isEmpty) { + throw Failure(message: 'An error occured, please try again later'); + } + + return responseBody; + } on SocketException catch (_) { + throw Failure(message: 'No internet connection'); + } on TimeoutException catch (_) { + throw Failure(message: 'Session timeout'); + } on DioException catch (err) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + + if (err.response?.statusCode == 401) { + throw Failure( + message: 'Session timeout', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 404) { + throw Failure( + message: 'Not found', + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 422) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (err.response?.statusCode == 500) { + throw Failure( + // ignore: avoid_dynamic_calls + message: err.response?.data['message'] as String, + statusCode: err.response?.statusCode, + ); + } + + if (DioExceptionType.unknown == err.type) { + _logger + ..d('Error: $err') + ..i('${err.response?.statusCode}') + ..i('Error: ${err.response?.data}'); + throw Exception('Server error'); + } else if (DioExceptionType.connectionTimeout == err.type) { + throw const SocketException('No internet connection'); + } else if (DioExceptionType.connectionError == err.type) { + throw const SocketException('No Internet Connection'); + } + throw Exception('Server error'); + } + } +} diff --git a/lib/common/utils/router.dart b/lib/common/utils/router.dart new file mode 100644 index 0000000..3ba3606 --- /dev/null +++ b/lib/common/utils/router.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/features/auth/ui/sign_in.dart'; +import 'package:fluttercon/features/dashboard/ui/dashboard_screen.dart'; +import 'package:fluttercon/features/splash/splash_screen.dart'; +import 'package:go_router/go_router.dart'; + +class FlutterConRouter { + static GoRouter get router => _router; + + static const String decisionRoute = '/'; + static const String signInRoute = '/sign-in'; + static const String dashboardRoute = '/dashboard'; + + static final GlobalKey _globalNavigatorKey = + GlobalKey(); + + static final _router = GoRouter( + initialLocation: decisionRoute, + navigatorKey: _globalNavigatorKey, + routes: [ + GoRoute( + path: decisionRoute, + name: decisionRoute, + builder: (context, state) => const SplashScreen(), + ), + GoRoute( + path: signInRoute, + name: signInRoute, + builder: (context, state) => const SignInScreen(), + ), + GoRoute( + path: dashboardRoute, + name: dashboardRoute, + builder: (context, state) => const DashboardScreen(), + ), + ], + ); +} diff --git a/lib/common/widgets/app_bar/app_bar.dart b/lib/common/widgets/app_bar/app_bar.dart index 9d4665e..b7525fe 100644 --- a/lib/common/widgets/app_bar/app_bar.dart +++ b/lib/common/widgets/app_bar/app_bar.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/widgets/app_bar/feedback_button.dart'; import 'package:fluttercon/common/widgets/app_bar/user_profile_icon.dart'; -import 'package:fluttercon/core/di/injectable.dart'; -import 'package:fluttercon/core/local_storage.dart'; class CustomAppBar extends StatefulWidget { const CustomAppBar({required this.selectedIndex, super.key}); @@ -15,7 +13,6 @@ class CustomAppBar extends StatefulWidget { class _CustomBottomNavigationBarState extends State { /// This is used for the swipe drag gesture on the bottom nav bar - LocalStorage localStorage = getIt(); @override Widget build(BuildContext context) { diff --git a/lib/common/widgets/bottom_nav/bottom_nav_bar.dart b/lib/common/widgets/bottom_nav/bottom_nav_bar.dart index 8eb4af8..c7ce0ec 100644 --- a/lib/common/widgets/bottom_nav/bottom_nav_bar.dart +++ b/lib/common/widgets/bottom_nav/bottom_nav_bar.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/common/widgets/bottom_nav/app_nav_icon.dart'; import 'package:fluttercon/common/widgets/page_item.dart'; -import 'package:fluttercon/core/di/injectable.dart'; -import 'package:fluttercon/core/local_storage.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; /// Custom Bottom Navigation Bar that will handles the page to be displayed on @@ -26,7 +24,7 @@ class CustomBottomNavigationBar extends StatefulWidget { class _CustomBottomNavigationBarState extends State { /// This is used for the swipe drag gesture on the bottom nav bar - LocalStorage localStorage = getIt(); + bool bottomNavBarSwipeGestures = false; bool bottomNavBarDoubleTapGestures = false; diff --git a/lib/core/di/injectable.dart b/lib/core/di/injectable.dart index f915d9d..88b975d 100644 --- a/lib/core/di/injectable.dart +++ b/lib/core/di/injectable.dart @@ -1,11 +1,9 @@ import 'dart:convert'; -import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:fluttercon/core/di/injectable.config.dart'; import 'package:get_it/get_it.dart'; import 'package:injectable/injectable.dart'; -import 'package:shared_preferences/shared_preferences.dart'; final getIt = GetIt.instance; @@ -14,19 +12,12 @@ final getIt = GetIt.instance; generateForDir: ['lib'], ) Future configureDependencies() async { - await getIt.initGetIt(); + getIt.initGetIt(); await getIt.allReady(); } @module -abstract class RegisterModule { - @singleton - @preResolve - Future prefs() => SharedPreferences.getInstance(); - - @singleton - Dio dio() => Dio(); -} +abstract class RegisterModule {} dynamic _parseAndDecode(String response) => jsonDecode(response); diff --git a/lib/core/local_storage.dart b/lib/core/local_storage.dart deleted file mode 100644 index f430077..0000000 --- a/lib/core/local_storage.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fluttercon/common/utils/constants/pref_constants.dart'; -import 'package:injectable/injectable.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -/// A class based on Shared Preferences for managing basic stuff like themes -@singleton -abstract class LocalStorage { - @factoryMethod - factory LocalStorage(SharedPreferences prefs) = LocalStorageImp; - - ThemeMode getThemeMode(); - - Future updateThemeMode(ThemeMode themeMode); - - bool getPrefBool(String settingsKey); - int getPrefInt(String settingsKey); - String getPrefString(String settingsKey); - - void setPrefBool(String settingsKey, {required bool settingsValue}); - void setPrefInt(String settingsKey, int settingsValue); - void setPrefString(String settingsKey, String settingsValue); - - void clearData(); - void removeKeyPair(String settingsKey); - bool keyExists(String settingsKey); -} - -class LocalStorageImp implements LocalStorage { - LocalStorageImp(this.sharedPrefs); - final SharedPreferences sharedPrefs; - - @override - Future updateThemeMode(ThemeMode themeMode) async { - await sharedPrefs.setInt( - PrefConstants.appThemeKey, - int.parse(themeMode.toString()), - ); - } - - @override - ThemeMode getThemeMode() { - switch (sharedPrefs.getString(PrefConstants.appThemeKey)) { - case 'ThemeMode.light': - return ThemeMode.light; - - case 'ThemeMode.dark': - return ThemeMode.dark; - - default: - return ThemeMode.system; - } - } - - @override - void clearData() { - sharedPrefs.remove(PrefConstants.appThemeKey); - } - - @override - void removeKeyPair(String key) { - sharedPrefs.remove(key); - } - - @override - bool keyExists(String key) { - return sharedPrefs.containsKey(key); - } - - @override - bool getPrefBool(String settingsKey) { - return sharedPrefs.getBool(settingsKey) ?? false; - } - - @override - int getPrefInt(String settingsKey) { - return sharedPrefs.getInt(settingsKey) ?? 0; - } - - @override - String getPrefString(String settingsKey) { - return sharedPrefs.getString(settingsKey) ?? ''; - } - - @override - void setPrefBool(String settingsKey, {required bool settingsValue}) { - if (!settingsValue) { - sharedPrefs.remove(settingsKey); - return; - } - sharedPrefs.setBool(settingsKey, settingsValue); - } - - @override - void setPrefInt(String settingsKey, int settingsValue) { - if (settingsValue.isNegative) { - sharedPrefs.remove(settingsKey); - return; - } - sharedPrefs.setInt(settingsKey, settingsValue); - } - - @override - void setPrefString(String settingsKey, String settingsValue) { - if (settingsValue.isEmpty) { - sharedPrefs.remove(settingsKey); - return; - } - sharedPrefs.setString(settingsKey, settingsValue); - } -} diff --git a/lib/core/navigator/main_navigation.dart b/lib/core/navigator/main_navigation.dart deleted file mode 100644 index e6bd138..0000000 --- a/lib/core/navigator/main_navigation.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -/// An abstract class representing the main navigation functionality. -abstract class MainNavigation {} - -/// A mixin that provides navigation functionality to a [State] class. -/// Classes that use this mixin must extend [State] and implement -/// [MainNavigation]. -mixin MainNavigationMixin on State - implements MainNavigation {} diff --git a/lib/core/navigator/main_navigator.dart b/lib/core/navigator/main_navigator.dart deleted file mode 100644 index c3c8e1d..0000000 --- a/lib/core/navigator/main_navigator.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fluttercon/common/widgets/text_scale_factor.dart'; -import 'package:fluttercon/core/navigator/main_navigation.dart'; -import 'package:fluttercon/core/navigator/route_names.dart'; -import 'package:fluttercon/features/dashboard/ui/dashboard_screen.dart'; -import 'package:fluttercon/features/splash/splash_screen.dart'; - -part 'main_navigator_state.dart'; - -/// The MainNavigator class is a StatefulWidget that serves as the primary -/// navigator for the application. It manages the navigation stack and routes. -/// -/// The MainNavigator widget contains a child widget that is displayed within -/// its navigation context. -class MainNavigator extends StatefulWidget { - const MainNavigator({ - this.child, - super.key, - }); - final Widget? child; - - @override - MainNavigatorState createState() => MainNavigatorState(); - - /// A static method to retrieve the nearest MainNavigationMixin instance - /// from the provided [context]. This is useful for performing navigation - /// operations within the application. - /// - /// The [rootNavigator] parameter determines whether to search from the - /// root ancestor or the nearest ancestor. - /// - /// Throws a [FlutterError] if no MainNavigationMixin is found in the - /// widget tree. - static MainNavigationMixin of( - BuildContext context, { - bool rootNavigator = false, - }) { - final navigator = rootNavigator - ? context.findRootAncestorStateOfType() - : context.findAncestorStateOfType(); - const missingNavigationError = - 'MainNavigation operation requested with a context that does not ' - 'include a MainNavigation.\n' - 'The context used to push or pop routes from the MainNavigation must ' - 'be that of a widget that is a descendant of a MainNavigator widget.'; - assert( - () { - if (navigator == null) { - throw FlutterError(missingNavigationError); - } - return true; - }(), - missingNavigationError, - ); - return navigator!; - } -} diff --git a/lib/core/navigator/main_navigator_state.dart b/lib/core/navigator/main_navigator_state.dart deleted file mode 100644 index 87bfbd4..0000000 --- a/lib/core/navigator/main_navigator_state.dart +++ /dev/null @@ -1,83 +0,0 @@ -part of 'main_navigator.dart'; - -/// The MainNavigatorState class is the state associated with the MainNavigator -/// widget. It includes methods and properties for managing navigation within -/// the application. -class MainNavigatorState extends State with MainNavigationMixin { - static final GlobalKey _navigationKey = - GlobalKey(); - static final List _navigatorObservers = []; - - /// The initial route for the navigator. - static String get initialRoute => RouteNames.splash; - - /// A global key for accessing the navigator state. - static GlobalKey get navigationKey => _navigationKey; - - /// A list of navigator observers for monitoring navigation events. - static List get navigatorObservers => _navigatorObservers; - - /// A getter for accessing the current state of the navigator. - NavigatorState get navigator => _navigationKey.currentState!; - - @override - Widget build(BuildContext context) { - return TextScaleFactor( - child: widget.child ?? const SizedBox.shrink(), - ); - } - - /// A static method for generating routes based on the given [settings]. It - /// maps route names to the corresponding widget builders. - /// - /// If the route name does not match any of the defined routes, it defaults - /// to the SplashScreen. - /// - /// Example route settings: - /// ```dart - /// RouteSettings(name: RouteNames.login) - /// ``` - static Route? onGenerateRoute(RouteSettings settings) { - final strippedPath = settings.name?.replaceFirst('/', ''); - final routes = { - // Splash Screens - '': (context) => const SplashScreen(), - RouteNames.splash: (context) => const SplashScreen(), - - // Auth Screens - //RouteNames.login: (context) => const LoginScreen(), - //RouteNames.signup: (context) => const SignupScreen(), - //RouteNames.otp: (context) => const OtpScreen(), - - // Home Screens - RouteNames.dashboard: (context) => const DashboardScreen(), - }; - - SplashScreen defaultRoute(context) => const SplashScreen(); - - WidgetBuilder? getRouteBuilder(String routeName) { - if (routes.containsKey(routeName)) { - return routes[routeName]; - } else { - return defaultRoute; - } - } - - MaterialPageRoute createMaterialPageRoute( - WidgetBuilder builder, - RouteSettings settings, - ) { - return MaterialPageRoute( - builder: builder, - settings: settings, - ); - } - - final routeBuilder = getRouteBuilder(strippedPath!); - if (routeBuilder != null) { - return createMaterialPageRoute(routeBuilder, settings); - } else { - return null; - } - } -} diff --git a/lib/core/navigator/route_names.dart b/lib/core/navigator/route_names.dart deleted file mode 100644 index 6261307..0000000 --- a/lib/core/navigator/route_names.dart +++ /dev/null @@ -1,15 +0,0 @@ -/// all the routes used in this app -class RouteNames { - RouteNames._(); - - // splash routes - static const splash = 'splash'; - - static const login = 'login'; - static const signup = 'signup'; - - // user routes - static const account = 'account'; - static const dashboard = 'dashboard'; - static const home = 'home'; -} diff --git a/lib/features/auth/cubit/google_sign_in_cubit.dart b/lib/features/auth/cubit/google_sign_in_cubit.dart new file mode 100644 index 0000000..a8a5652 --- /dev/null +++ b/lib/features/auth/cubit/google_sign_in_cubit.dart @@ -0,0 +1,31 @@ +import 'package:bloc/bloc.dart'; +import 'package:fluttercon/common/data/models/failure.dart'; +import 'package:fluttercon/common/repository/auth_repository.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'google_sign_in_state.dart'; +part 'google_sign_in_cubit.freezed.dart'; + +class GoogleSignInCubit extends Cubit { + GoogleSignInCubit({ + required AuthRepository authRepository, + }) : super(const GoogleSignInState.initial()) { + _authRepository = authRepository; + } + + late AuthRepository _authRepository; + + Future signInWithGoogle() async { + emit(const GoogleSignInState.loading()); + try { + final token = await _authRepository.signInWithGoogle(); + emit(GoogleSignInState.loaded(token: token)); + } on Failure catch (e) { + emit(GoogleSignInState.error(message: e.message)); + } catch (e) { + emit( + const GoogleSignInState.error(message: 'An unexpected error occured'), + ); + } + } +} diff --git a/lib/features/auth/cubit/google_sign_in_state.dart b/lib/features/auth/cubit/google_sign_in_state.dart new file mode 100644 index 0000000..ac908e7 --- /dev/null +++ b/lib/features/auth/cubit/google_sign_in_state.dart @@ -0,0 +1,13 @@ +part of 'google_sign_in_cubit.dart'; + +@freezed +class GoogleSignInState with _$GoogleSignInState { + const factory GoogleSignInState.initial() = _Initial; + const factory GoogleSignInState.loading() = _Loading; + const factory GoogleSignInState.loaded({ + required String token, + }) = _Loaded; + const factory GoogleSignInState.error({ + required String message, + }) = _Error; +} diff --git a/lib/features/auth/cubit/social_auth_sign_in_cubit.dart b/lib/features/auth/cubit/social_auth_sign_in_cubit.dart new file mode 100644 index 0000000..3d28b95 --- /dev/null +++ b/lib/features/auth/cubit/social_auth_sign_in_cubit.dart @@ -0,0 +1,38 @@ +import 'package:bloc/bloc.dart'; +import 'package:fluttercon/common/repository/auth_repository.dart'; +import 'package:fluttercon/common/repository/hive_repository.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'social_auth_sign_in_state.dart'; +part 'social_auth_sign_in_cubit.freezed.dart'; + +class SocialAuthSignInCubit extends Cubit { + SocialAuthSignInCubit({ + required AuthRepository authRepository, + required HiveRepository hiveRepository, + }) : super(const SocialAuthSignInState.initial()) { + _authRepository = authRepository; + _hiveRepository = hiveRepository; + } + + late AuthRepository _authRepository; + late HiveRepository _hiveRepository; + + Future socialSignIn({ + required String token, + }) async { + emit(const SocialAuthSignInState.loading()); + try { + final authResult = await _authRepository.signIn( + token: token, + ); + _hiveRepository + ..persistToken(authResult.token) + ..persistUser(authResult.user); + + emit(const SocialAuthSignInState.loaded()); + } catch (e) { + emit(SocialAuthSignInState.error(message: e.toString())); + } + } +} diff --git a/lib/features/auth/cubit/social_auth_sign_in_state.dart b/lib/features/auth/cubit/social_auth_sign_in_state.dart new file mode 100644 index 0000000..c0fa63f --- /dev/null +++ b/lib/features/auth/cubit/social_auth_sign_in_state.dart @@ -0,0 +1,11 @@ +part of 'social_auth_sign_in_cubit.dart'; + +@freezed +class SocialAuthSignInState with _$SocialAuthSignInState { + const factory SocialAuthSignInState.initial() = _Initial; + const factory SocialAuthSignInState.loading() = _Loading; + const factory SocialAuthSignInState.loaded() = _Loaded; + const factory SocialAuthSignInState.error({ + required String message, + }) = _Error; +} diff --git a/lib/features/auth/ui/sign_in.dart b/lib/features/auth/ui/sign_in.dart new file mode 100644 index 0000000..fa11d85 --- /dev/null +++ b/lib/features/auth/ui/sign_in.dart @@ -0,0 +1,76 @@ +import 'package:auth_buttons/auth_buttons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttercon/common/utils/constants/app_assets.dart'; +import 'package:fluttercon/common/utils/router.dart'; +import 'package:fluttercon/features/auth/cubit/google_sign_in_cubit.dart'; +import 'package:fluttercon/features/auth/cubit/social_auth_sign_in_cubit.dart'; +import 'package:go_router/go_router.dart'; + +class SignInScreen extends StatelessWidget { + const SignInScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + state.maybeWhen( + orElse: () {}, + loaded: (token) => + context.read().socialSignIn(token: token), + error: (message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + ), + ); + }, + ); + }, + child: BlocListener( + listener: (context, state) { + state.maybeWhen( + orElse: () {}, + loaded: () => + GoRouter.of(context).goNamed(FlutterConRouter.decisionRoute), + error: (message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + }, + ); + }, + child: Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + const Image(image: AssetImage(AppAssets.droidconLogo)), + const SizedBox(height: 64), + BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + loading: () => const CircularProgressIndicator(), + orElse: () => GoogleAuthButton( + onPressed: () async => context + .read() + .signInWithGoogle(), + ), + ); + }, + ), + const Spacer(), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/splash/splash_screen.dart b/lib/features/splash/splash_screen.dart index 4531ba7..ceb9774 100644 --- a/lib/features/splash/splash_screen.dart +++ b/lib/features/splash/splash_screen.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:fluttercon/common/repository/hive_repository.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; -import 'package:fluttercon/core/navigator/route_names.dart'; +import 'package:fluttercon/common/utils/router.dart'; +import 'package:fluttercon/core/di/injectable.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logger/logger.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -10,32 +14,41 @@ class SplashScreen extends StatefulWidget { } class _SplashScreenState extends State { + void _redirectToPage( + BuildContext context, + String routeName, + ) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => GoRouter.of(context).goNamed(routeName), + ); + } + @override void initState() { - super.initState(); - nextAction(); - } + final accessToken = getIt().retrieveToken(); + Logger().d(accessToken); - Future nextAction() async { - await Future.delayed(const Duration(seconds: 3), () {}); - if (!mounted) return; - await Navigator.pushNamedAndRemoveUntil( - context, - RouteNames.dashboard, - (route) => false, - ); + if (accessToken == null) { + _redirectToPage( + context, + FlutterConRouter.signInRoute, + ); + } else { + _redirectToPage( + context, + FlutterConRouter.dashboardRoute, + ); + } + + super.initState(); } @override Widget build(BuildContext context) { - return Scaffold( - body: Container( - margin: const EdgeInsets.all(40), - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage(AppAssets.imgDroidcon), - fit: BoxFit.fitWidth, - ), + return const Scaffold( + body: Center( + child: Image( + image: AssetImage(AppAssets.imgDroidcon), ), ), ); diff --git a/lib/main_development.dart b/lib/main_development.dart index a51f3c1..a06e317 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/app.dart'; import 'package:fluttercon/bootstrap.dart'; import 'package:fluttercon/common/utils/env/flavor_config.dart'; -import 'package:fluttercon/core/di/injectable.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -11,10 +10,9 @@ Future main() async { values: FlutterConValues( baseDomain: 'api.droidcon.co.ke', urlScheme: 'https', + hiveBox: 'fluttercon-dev', ), ); - await configureDependencies(); - await bootstrap(() => const MyApp()); } diff --git a/lib/main_production.dart b/lib/main_production.dart index a51f3c1..f4311eb 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/app.dart'; import 'package:fluttercon/bootstrap.dart'; import 'package:fluttercon/common/utils/env/flavor_config.dart'; -import 'package:fluttercon/core/di/injectable.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -11,10 +10,9 @@ Future main() async { values: FlutterConValues( baseDomain: 'api.droidcon.co.ke', urlScheme: 'https', + hiveBox: 'fluttercon', ), ); - await configureDependencies(); - await bootstrap(() => const MyApp()); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index a51f3c1..b939ebc 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/app.dart'; import 'package:fluttercon/bootstrap.dart'; import 'package:fluttercon/common/utils/env/flavor_config.dart'; -import 'package:fluttercon/core/di/injectable.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -11,10 +10,9 @@ Future main() async { values: FlutterConValues( baseDomain: 'api.droidcon.co.ke', urlScheme: 'https', + hiveBox: 'fluttercon-stg', ), ); - await configureDependencies(); - await bootstrap(() => const MyApp()); } diff --git a/pubspec.lock b/pubspec.lock index f29d1d7..29973cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + auth_buttons: + dependency: "direct main" + description: + name: auth_buttons + sha256: "2407885514c7e56215de2e5d8b3516647bd006bdd9b9e7347b2faf27faf5ac98" + url: "https://pub.dev" + source: hosted + version: "3.0.3" bloc: dependency: "direct main" description: @@ -329,6 +337,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.9+1" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: b0dde65595b65c0c2c2c2127da0ce7772a375fcefbea8490c1563a4aecbd0195 + url: "https://pub.dev" + source: hosted + version: "5.1.3" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "0408e2ed74b1afa0490a93aa041fe90d7573af7ffc59a641edc6c5b5c1b8d2a4" + url: "https://pub.dev" + source: hosted + version: "7.4.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: "7e0c6d0fa8c5c1b2ae126a78f2d1a206a77a913f78d20f155487bf746162dccc" + url: "https://pub.dev" + source: hosted + version: "5.12.5" firebase_core: dependency: "direct main" description: @@ -477,6 +509,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "39dd52168d6c59984454183148dc3a5776960c61083adfc708cc79a7b3ce1ba8" + url: "https://pub.dev" + source: hosted + version: "14.2.1" + google_fonts: + dependency: transitive + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: c6a67eb2117d3954a95afe307c3347d5b7ead5f5ef78bd94b1a62d161f6335a8 + url: "https://pub.dev" + source: hosted + version: "0.3.1+3" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "0b8787cb9c1a68ad398e8010e8c8766bfa33556d2ab97c439fb4137756d7308f" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "5a47ebec9af97daf0822e800e4f101c3340b5ebc3f6898cf860c1a71b53cf077" + url: "https://pub.dev" + source: hosted + version: "6.1.28" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: a058c9880be456f21e2e8571c1126eaacd570bdc5b6c6d9d15aea4bdf22ca9fe + url: "https://pub.dev" + source: hosted + version: "5.7.6" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "1f6e5787d7a120cc0359ddf315c92309069171306242e181c09472d1b00a2971" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d606264c7a1a526a3aa79d938b85a601d8589731a478bd4a3dcbdeb14a572228 + url: "https://pub.dev" + source: hosted + version: "0.12.4+1" graphs: dependency: transitive description: @@ -485,6 +581,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" html: dependency: transitive description: @@ -621,6 +741,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" + url: "https://pub.dev" + source: hosted + version: "2.4.0" logging: dependency: transitive description: @@ -789,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + pretty_dio_logger: + dependency: "direct main" + description: + name: pretty_dio_logger + sha256: "36f2101299786d567869493e2f5731de61ce130faa14679473b26905a92b6407" + url: "https://pub.dev" + source: hosted + version: "1.4.0" provider: dependency: transitive description: @@ -829,62 +965,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" - url: "https://pub.dev" - source: hosted - version: "2.3.2" shelf: dependency: transitive description: @@ -1172,4 +1252,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index a425940..eac531b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: '>=3.3.1 <4.0.0' dependencies: + auth_buttons: ^3.0.3 bloc: ^8.1.4 bloc_concurrency: ^0.2.5 cached_network_image: ^3.3.1 @@ -14,6 +15,7 @@ dependencies: dio: ^5.5.0+1 equatable: ^2.0.5 firebase_analytics: ^11.2.1 + firebase_auth: ^5.1.3 firebase_core: ^3.3.0 firebase_crashlytics: ^4.0.4 flutter: @@ -24,11 +26,16 @@ dependencies: flutter_svg: ^2.0.10+1 freezed_annotation: ^2.4.4 get_it: ^7.7.0 + go_router: ^14.2.1 + google_sign_in: ^6.2.1 + hive: ^2.2.3 + hive_flutter: ^1.1.0 injectable: ^2.4.4 intl: ^0.19.0 json_annotation: ^4.9.0 + logger: ^2.4.0 + pretty_dio_logger: ^1.4.0 rxdart: ^0.28.0 - shared_preferences: ^2.2.3 sizer: ^2.0.15 stream_transform: ^2.1.0 styled_widget: ^0.4.1 @@ -40,7 +47,8 @@ dev_dependencies: flutter_native_splash: ^2.4.1 flutter_test: sdk: flutter - freezed: ^2.3.5 + freezed: ^2.5.2 + hive_generator: ^2.0.1 icons_launcher: ^2.1.7 injectable_generator: ^2.6.2 json_serializable: ^6.8.0 diff --git a/test/.gitkeep b/test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/repository/repository_test.dart b/test/repository/repository_test.dart deleted file mode 100644 index 07529d3..0000000 --- a/test/repository/repository_test.dart +++ /dev/null @@ -1,284 +0,0 @@ -// ignore_for_file: lines_longer_than_80_chars - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fluttercon/common/data/models/models.dart'; -import 'package:fluttercon/common/repository/repository.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockDio extends Mock implements Dio {} - -class MockResponse extends Mock implements Response {} - -void main() { - group( - 'Repository tests', - () { - late Dio dio; - late MockResponse> response; - - setUp(() { - dio = MockDio(); - response = MockResponse(); - }); - - group('Speaker Tests', () { - test('Fetches Speakers correctly', () async { - when( - () => dio.get>( - any(), - queryParameters: any(named: 'queryParameters'), - ), - ).thenAnswer((_) async => response); - - when(() => response.statusCode).thenReturn(200); - when(() => response.data).thenReturn(speakersMap); - - final repository = Repository(dio: dio); - final speakers = await repository.fetchSpeakers(event: 'flutterCon'); - - expect( - speakers, - isA>().having( - (speakers) => speakers.length, - 'Fetched 2 speakers', - equals(2), - ), - ); - }); - - test( - 'Throws an exception if the response status code is not 200', - () { - when( - () => dio.get>( - any(), - queryParameters: any(named: 'queryParameters'), - ), - ).thenAnswer((_) async => response); - - when(() => response.statusCode).thenReturn(400); - when(() => response.statusMessage).thenReturn('Resouce not found'); - - final repository = Repository(dio: dio); - - expect( - repository.fetchSpeakers(event: 'flutterCon'), - throwsException, - ); - }, - ); - }); - - group('Room Tests', () { - test('Fetches Rooms correctly', () async { - when( - () => dio.get>( - any(), - queryParameters: any(named: 'queryParameters'), - ), - ).thenAnswer((_) async => response); - - when(() => response.statusCode).thenReturn(200); - when(() => response.data).thenReturn(roomsMap); - - final repository = Repository(dio: dio); - final rooms = await repository.fetchRooms(event: 'flutterCon'); - - expect( - rooms, - isA>(), - ); - }); - - test( - 'Throws an exception if the response status code is not 200', - () { - when( - () => dio.get>( - any(), - queryParameters: any(named: 'queryParameters'), - ), - ).thenAnswer((_) async => response); - - when(() => response.statusCode).thenReturn(400); - when(() => response.statusMessage).thenReturn('Resouce not found'); - - final repository = Repository(dio: dio); - - expect( - repository.fetchRooms(event: 'flutterCon'), - throwsException, - ); - }, - ); - }); - - group('Session Tests', () { - test('Fetches Sessions correctly', () async { - when( - () => dio.get>( - any(), - queryParameters: any(named: 'queryParameters'), - ), - ).thenAnswer((_) async => response); - - when(() => response.statusCode).thenReturn(200); - when(() => response.data).thenReturn(sessionsMap); - - final repository = Repository(dio: dio); - final sessions = await repository.fetchSessions(event: 'flutterCon'); - - expect( - sessions, - isA>(), - ); - }); - - test( - 'Throws an exception if the response status code is not 200', - () { - when( - () => dio.get>( - any(), - queryParameters: any(named: 'queryParameters'), - ), - ).thenAnswer((_) async => response); - - when(() => response.statusCode).thenReturn(400); - when(() => response.statusMessage).thenReturn('Resouce not found'); - - final repository = Repository(dio: dio); - - expect( - repository.fetchSessions(event: 'flutterCon'), - throwsException, - ); - }, - ); - }); - }, - ); -} - -// All these copied from postman files - -const speakersMap = { - 'data': [ - { - 'name': 'Charles Muchene', - 'tagline': 'SenseiDev', - 'biography': - 'Lead Mobile Engineer at a motorcycle ride sharing company in Kampala, Uganda. Freshly brewed café latte does the magic. :D', - 'avatar': - 'https://sessionize.com/image?f=b8c9f0300f2d7242f78c4df95bf297f4,400,400,1,0,79-31e3-4c9c-88fe-30d51990bf64.08ff2466-3b86-4bb5-9292-b4b493df6e6f.JPG', - 'twitter': 'https://twitter.com/charlesmuchene', - 'facebook': null, - 'linkedin': null, - 'instagram': null, - 'blog': null, - 'company_website': 'http://www.safeboda.com', - }, - { - 'name': 'Clare Mburu', - 'tagline': 'CTO,BabyPie', - 'biography': - 'A passionate technology enthusiast, Android Web applications developer, community lover', - 'avatar': - 'https://sessionize.com/image?f=df156e6bac7b2939d702bff0f158b079,400,400,1,0,1f-e683-41e9-9310-ad43170d265c.2e3da599-e070-4753-a169-cd35f5577464.jpg', - 'twitter': 'https://twitter.com/Mburuclare?s=09', - 'facebook': null, - 'linkedin': null, - 'instagram': null, - 'blog': null, - 'company_website': null, - } - ], - 'meta': { - 'paginator': { - 'count': 27, - 'per_page': '15', - 'current_page': 1, - 'next_page': - 'http://localhost:8000/api/v1/events/droidconke2019-444/speakers?per_page=15&page=2', - 'has_more_pages': true, - 'next_page_url': - 'http://localhost:8000/api/v1/events/droidconke2019-444/speakers?per_page=15&page=2', - 'previous_page_url': null, - }, - }, -}; - -const roomsMap = { - 'data': [ - {'title': 'Room A', 'id': 1}, - {'title': 'Room B', 'id': 2}, - {'title': 'Room C', 'id': 3}, - ], -}; - -const sessionsMap = { - 'data': [ - { - 'title': 'Retrofiti: A Pragmatic Approach to using Retrofit in Android', - 'description': - 'This session is codelab covering some of the best practices and recommended approaches to building an application using the retrofit library.', - 'slug': - 'retrofiti-a-pragmatic-approach-to-using-retrofit-in-android-1583941090', - 'session_format': 'Codelab / Workshop', - 'session_level': 'Intermediate', - 'speakers': [ - { - 'name': 'Roger Taracha', - 'tagline': 'TheDancerCodes', - 'biography': - "Roger Taracha is a Software Engineer by profession who also loves the Arts. \r\n\r\nWhen he isn't writing code or mentoring software developers, you can find him throwing down on the dance floor.\r\nHence his alias, TheDancerCodes.\r\n\r\nHe is currently a Learning Facilitator and Lead Android Engineer at Andela Kenya where he drives teams of software developers (junior and senior) to rapidly develop great software products. \r\n\r\nHe also supports the learning and professional development of dozens of Africa's most talented software developers every day.", - 'avatar': - 'https://sessionize.com/image?f=b365d37c5064aa5ca332ca036b08f6ec,400,400,1,0,aa-600e-4800-bfee-afe74104c8e8.6708c35c-8578-40f1-aa56-d8991598d826.jpg', - 'twitter': 'https://twitter.com/TheDancerCodes', - 'facebook': null, - 'linkedin': null, - 'instagram': null, - 'blog': 'https://medium.com/@thedancercodes', - 'company_website': 'https://andela.com/', - } - ], - }, - { - 'title': 'Jetpack: An Overview', - 'description': - "During Google IO 2018, the Android team announced Jetpack, a set of libraries, tools and architectural guidance to help make it quick and easy to build great Android apps. Jetpack includes the previously existing Architecture Components and adds many libraries and tools that make development easier across a wide range of areas. In this session, we'll go through the available libraries and APIs and discuss how they can make development easier, faster and more intuitive.", - 'slug': 'jetpack-an-overview-1583941090', - 'session_format': 'Regular Session', - 'session_level': 'Intermediate', - 'speakers': [ - { - 'name': 'Eston Karumbi', - 'tagline': 'Android Developer', - 'biography': 'Developer, learner, wanderer, DIY auto mechanic.', - 'avatar': - 'https://sessionize.com/image?f=f0a21786344cb927ecf4ca9f6b8cd10e,400,400,1,0,a3-ce70-41a6-b987-2afe691f0864.0311c9f2-10e0-41af-82f4-c660e307c405.jpg', - 'twitter': 'https://twitter.com/doc2dev', - 'facebook': null, - 'linkedin': null, - 'instagram': null, - 'blog': null, - 'company_website': null, - } - ], - } - ], - 'meta': { - 'paginator': { - 'count': 2, - 'per_page': '20', - 'current_page': 1, - 'next_page': - 'http://localhost:8000/api/v1/events/droidconke2019-444/sessions?per_page=20&page=2', - 'has_more_pages': true, - 'next_page_url': - 'http://localhost:8000/api/v1/events/droidconke2019-444/sessions?per_page=20&page=2', - 'previous_page_url': null, - }, - }, -}; diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 4e00f85..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:fluttercon/app.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}