diff --git a/assets/fonts/Poppins-Regular.ttf b/assets/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..9f0c71b Binary files /dev/null and b/assets/fonts/Poppins-Regular.ttf differ diff --git a/assets/icons/logo.png b/assets/icons/app_icon/logo.png similarity index 100% rename from assets/icons/logo.png rename to assets/icons/app_icon/logo.png diff --git a/assets/icons/logo_foreground.png b/assets/icons/app_icon/logo_foreground.png similarity index 100% rename from assets/icons/logo_foreground.png rename to assets/icons/app_icon/logo_foreground.png diff --git a/lib/app/l10n/arb/app_de.arb b/lib/app/l10n/arb/app_de.arb index 8ba7534..ab1e890 100644 --- a/lib/app/l10n/arb/app_de.arb +++ b/lib/app/l10n/arb/app_de.arb @@ -166,9 +166,9 @@ } } }, - "analyzeText": "Text Analysieren", + "analyzeText": "Analysieren", "@analyzeText": { - "description": "Analyze Text button text on the OnboardingView", + "description": "Analyze button text on the OnboardingView", "type": "text", "placeholders": {} } diff --git a/lib/app/l10n/arb/app_en.arb b/lib/app/l10n/arb/app_en.arb index 067bd90..830a275 100644 --- a/lib/app/l10n/arb/app_en.arb +++ b/lib/app/l10n/arb/app_en.arb @@ -166,9 +166,9 @@ } } }, - "analyzeText": "Analyze Text", + "analyzeText": "Analyze", "@analyzeText": { - "description": "Analyze Text button text on the OnboardingView", + "description": "Analyze button text on the OnboardingView", "type": "text", "placeholders": {} } diff --git a/lib/app/theme/base/base_theme.dart b/lib/app/theme/base/base_theme.dart index 18904af..3685ac5 100644 --- a/lib/app/theme/base/base_theme.dart +++ b/lib/app/theme/base/base_theme.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:gpt_detector/app/theme/theme_constants.dart'; -import 'package:gpt_detector/app/theme/theme_extensions/theme_extensions.dart'; abstract base class BaseTheme { Brightness get brightness; - Iterable> get extensions; + Iterable> get extensions; ThemeData get theme { return ThemeData( - useMaterial3: true, + fontFamily: 'Poppins', brightness: brightness, extensions: extensions, colorSchemeSeed: Colors.deepPurple, @@ -22,51 +21,47 @@ abstract base class BaseTheme { ); } - AppBarTheme get _appBarTheme { - return const AppBarTheme( - centerTitle: true, - ); - } - - CardTheme get _cardTheme { - return CardTheme( - margin: EdgeInsets.zero, - elevation: ThemeConstants.elevation, - shape: RoundedRectangleBorder( - borderRadius: ThemeConstants.borderRadiusCircular, - ), - ); - } + final AppBarTheme _appBarTheme = const AppBarTheme( + centerTitle: true, + ); - DialogTheme get _dialogTheme { - return DialogTheme( - elevation: ThemeConstants.elevation, - shape: RoundedRectangleBorder( - borderRadius: ThemeConstants.borderRadiusCircular, - ), - ); - } + final CardTheme _cardTheme = CardTheme( + margin: EdgeInsets.zero, + elevation: ThemeConstants.elevation, + shape: RoundedRectangleBorder( + borderRadius: ThemeConstants.borderRadiusCircular, + ), + ); - final ExpansionTileThemeData _expansionTileThemeData = - const ExpansionTileThemeData(tilePadding: EdgeInsets.zero, shape: Border()); + final DialogTheme _dialogTheme = DialogTheme( + elevation: ThemeConstants.elevation, + shape: RoundedRectangleBorder( + borderRadius: ThemeConstants.borderRadiusCircular, + ), + ); - final ListTileThemeData _listTileThemeData = const ListTileThemeData(contentPadding: EdgeInsets.zero); + final ExpansionTileThemeData _expansionTileThemeData = const ExpansionTileThemeData( + tilePadding: EdgeInsets.zero, + shape: Border(), + ); - ElevatedButtonThemeData get _elevatedButtonTheme => ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - elevation: ThemeConstants.elevation, - minimumSize: const Size.fromHeight(kToolbarHeight), - shape: RoundedRectangleBorder( - borderRadius: ThemeConstants.borderRadiusCircular, - ), - ), - ); + final ListTileThemeData _listTileThemeData = const ListTileThemeData( + contentPadding: EdgeInsets.zero, + ); - InputDecorationTheme get _inputDecorationTheme { - return InputDecorationTheme( - border: OutlineInputBorder( + final ElevatedButtonThemeData _elevatedButtonTheme = ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: ThemeConstants.elevation, + minimumSize: const Size.fromHeight(kToolbarHeight), + shape: RoundedRectangleBorder( borderRadius: ThemeConstants.borderRadiusCircular, ), - ); - } + ), + ); + + final InputDecorationTheme _inputDecorationTheme = InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: ThemeConstants.borderRadiusCircular, + ), + ); } diff --git a/lib/app/theme/dark/dark_theme.dart b/lib/app/theme/dark/dark_theme.dart index 35de7d3..5046bd1 100644 --- a/lib/app/theme/dark/dark_theme.dart +++ b/lib/app/theme/dark/dark_theme.dart @@ -9,8 +9,8 @@ final class DarkTheme extends BaseTheme { Brightness get brightness => Brightness.dark; @override - Iterable> get extensions => [ - ThemeExtensions( + Iterable> get extensions => [ + AppThemeExtensions( humanContent: const Color(0xFF479985), aiContent: const Color(0xFF93000A), mixedContent: const Color(0xFFFF7E79), diff --git a/lib/app/theme/light/light_theme.dart b/lib/app/theme/light/light_theme.dart index 75fa4b1..7101f60 100644 --- a/lib/app/theme/light/light_theme.dart +++ b/lib/app/theme/light/light_theme.dart @@ -9,8 +9,8 @@ final class LightTheme extends BaseTheme { Brightness get brightness => Brightness.light; @override - Iterable> get extensions => [ - ThemeExtensions( + Iterable> get extensions => [ + AppThemeExtensions( humanContent: const Color(0xFF007256), aiContent: const Color(0xFFBA1A1A), mixedContent: const Color(0xFFFF7E79), diff --git a/lib/app/theme/theme_constants.dart b/lib/app/theme/theme_constants.dart index 1ce3515..01d86ee 100644 --- a/lib/app/theme/theme_constants.dart +++ b/lib/app/theme/theme_constants.dart @@ -4,4 +4,8 @@ abstract final class ThemeConstants { static final BorderRadius borderRadiusCircular = BorderRadius.circular(12); static const Radius radiusCircular = Radius.circular(12); static const double elevation = 2; + + static const FontWeight fontWeightRegular = FontWeight.w400; + static const FontWeight fontWeightSemiBold = FontWeight.w500; + static const FontWeight fontWeightBold = FontWeight.w600; } diff --git a/lib/app/theme/theme_extensions/theme_extensions.dart b/lib/app/theme/theme_extensions/theme_extensions.dart index 153158f..6c489b4 100644 --- a/lib/app/theme/theme_extensions/theme_extensions.dart +++ b/lib/app/theme/theme_extensions/theme_extensions.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -class ThemeExtensions extends ThemeExtension { - ThemeExtensions({ +class AppThemeExtensions extends ThemeExtension { + AppThemeExtensions({ required this.humanContent, required this.mixedContent, required this.aiContent, @@ -12,11 +12,11 @@ class ThemeExtensions extends ThemeExtension { final Color? aiContent; @override - ThemeExtension lerp(ThemeExtension? other, double t) { - if (other is! ThemeExtensions) { + ThemeExtension lerp(ThemeExtension? other, double t) { + if (other is! AppThemeExtensions) { return this; } - return ThemeExtensions( + return AppThemeExtensions( humanContent: Color.lerp(humanContent, other.humanContent, t), mixedContent: Color.lerp(mixedContent, other.mixedContent, t), aiContent: Color.lerp(aiContent, other.aiContent, t), @@ -24,12 +24,12 @@ class ThemeExtensions extends ThemeExtension { } @override - ThemeExtensions copyWith({ + AppThemeExtensions copyWith({ Color? humanContent, Color? mixedContent, Color? aiContent, }) { - return ThemeExtensions( + return AppThemeExtensions( humanContent: humanContent ?? this.humanContent, mixedContent: mixedContent ?? this.mixedContent, aiContent: aiContent ?? this.aiContent, @@ -38,5 +38,5 @@ class ThemeExtensions extends ThemeExtension { @override String toString() => - 'ThemeExtensions(humanContent: $humanContent, mixedContent: $mixedContent, aiContent: $aiContent)'; + 'AppThemeExtensions(humanContent: $humanContent, mixedContent: $mixedContent, aiContent: $aiContent)'; } diff --git a/lib/app/widgets/gpt_elevated_button.dart b/lib/app/widgets/gpt_elevated_button.dart new file mode 100644 index 0000000..50abec3 --- /dev/null +++ b/lib/app/widgets/gpt_elevated_button.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:gpt_detector/app/theme/theme_constants.dart'; +import 'package:gpt_detector/core/extensions/context_extensions.dart'; + +class GPTElevatedButton extends StatelessWidget { + const GPTElevatedButton({required this.text, required this.onPressed, super.key, this.showingLoadingIndicator}); + + final bool? showingLoadingIndicator; + final void Function()? onPressed; + final String text; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.primary, + foregroundColor: context.colorScheme.onPrimary, + ), + onPressed: showingLoadingIndicator ?? false ? null : onPressed, + child: showingLoadingIndicator ?? false + ? const CircularProgressIndicator.adaptive() + : Text( + text, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onPrimary, + fontWeight: ThemeConstants.fontWeightBold, + ), + ), + ); + } +} diff --git a/lib/core/extensions/context_extensions.dart b/lib/core/extensions/context_extensions.dart index 67d5ccd..e23353c 100644 --- a/lib/core/extensions/context_extensions.dart +++ b/lib/core/extensions/context_extensions.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -extension MediaQueryExtension on BuildContext { +extension MediaQueryExtensions on BuildContext { MediaQueryData get mediaQuery => MediaQuery.of(this); /// Returns the height of the device @@ -40,7 +40,7 @@ extension MediaQueryExtension on BuildContext { double dynamicHeight(double val) => height * val; } -extension PaddingExtension on BuildContext { +extension PaddingExtensions on BuildContext { /// Adds 1% padding from all sides. EdgeInsets get paddingAllLow => EdgeInsets.all(lowValue); @@ -112,7 +112,7 @@ extension PaddingExtension on BuildContext { EdgeInsets get paddingBottomHigh => EdgeInsets.only(bottom: highValue); } -extension ThemeExtension on BuildContext { +extension ThemeExtensions on BuildContext { /// Get the theme data ThemeData get theme => Theme.of(this); @@ -121,4 +121,7 @@ extension ThemeExtension on BuildContext { /// Get the brightness Brightness get brightness => Theme.of(this).brightness; + + /// Get the color scheme + ColorScheme get colorScheme => Theme.of(this).colorScheme; } diff --git a/lib/core/utils/snackbar/snackbar_utils.dart b/lib/core/utils/snackbar/snackbar_utils.dart index 9088eb0..3bdc870 100644 --- a/lib/core/utils/snackbar/snackbar_utils.dart +++ b/lib/core/utils/snackbar/snackbar_utils.dart @@ -1,10 +1,25 @@ import 'package:flutter/material.dart'; import 'package:gpt_detector/app/constants/duration_constants.dart'; +import 'package:gpt_detector/app/theme/theme_constants.dart'; +import 'package:gpt_detector/core/extensions/context_extensions.dart'; abstract final class SnackbarUtils { static void showSnackbar({required BuildContext context, required String message}) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message), duration: DurationConstants.s4())); + ..showSnackBar( + SnackBar( + padding: EdgeInsets.all(context.defaultValue), + content: Text( + message, + style: context.textTheme.bodyLarge?.copyWith( + color: context.theme.colorScheme.onPrimary, + fontWeight: ThemeConstants.fontWeightSemiBold, + ), + ), + duration: DurationConstants.s4(), + behavior: SnackBarBehavior.fixed, + ), + ); } } diff --git a/lib/feature/detector/data/model/detector/detector_model.dart b/lib/feature/detector/data/model/detector/detector_model.dart index 4bc9fe8..543c08c 100644 --- a/lib/feature/detector/data/model/detector/detector_model.dart +++ b/lib/feature/detector/data/model/detector/detector_model.dart @@ -60,13 +60,13 @@ enum Classification { switch (this) { // Return default card color in case initial case Classification.initial: - return themeData.cardColor; + return themeData.secondaryHeaderColor; case Classification.human: - return themeData.extension()?.humanContent ?? themeData.cardColor; + return themeData.extension()?.humanContent ?? themeData.secondaryHeaderColor; case Classification.ai: - return themeData.extension()?.aiContent ?? themeData.cardColor; + return themeData.extension()?.aiContent ?? themeData.secondaryHeaderColor; case Classification.mixed: - return themeData.extension()?.mixedContent ?? themeData.cardColor; + return themeData.extension()?.mixedContent ?? themeData.secondaryHeaderColor; } } } diff --git a/lib/feature/detector/presentation/cubit/detector_cubit.dart b/lib/feature/detector/presentation/cubit/detector_cubit.dart index e12319c..ba3ee21 100644 --- a/lib/feature/detector/presentation/cubit/detector_cubit.dart +++ b/lib/feature/detector/presentation/cubit/detector_cubit.dart @@ -1,8 +1,11 @@ +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:form_inputs/form_inputs.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:gpt_detector/app/errors/failure.dart'; +import 'package:gpt_detector/app/l10n/extensions/app_l10n_extensions.dart'; +import 'package:gpt_detector/core/utils/snackbar/snackbar_utils.dart'; import 'package:gpt_detector/feature/detector/domain/entities/detector/detector_entity.dart'; import 'package:gpt_detector/feature/detector/domain/use_cases/detect_use_case.dart'; import 'package:gpt_detector/feature/detector/domain/use_cases/has_camera_permission_use_case.dart'; @@ -45,9 +48,21 @@ class DetectorCubit extends Cubit { emit(state.copyWith(hasGalleryPermission: hasGalleryPermission)); } - Future detectionRequested({required String text}) async { + Future detectionRequested({required BuildContext context, required String text}) async { + // Validate user input + final formStatus = UserInputForm.dirty(text); + if (formStatus.invalid) { + switch (formStatus.error) { + case UserInputFormError.tooShort: + SnackbarUtils.showSnackbar(context: context, message: context.l10n.textFieldHelperShortText); + case UserInputFormError.tooLong: + SnackbarUtils.showSnackbar(context: context, message: context.l10n.textFieldHelperLongText); + case null: + } + return; + } + // Call use case emit(state.copyWith(status: FormzStatus.submissionInProgress)); - final response = await _detectUseCase.call(text); response.fold( diff --git a/lib/feature/detector/presentation/view/detect_view.dart b/lib/feature/detector/presentation/view/detect_view.dart index d84dfdd..bff1c20 100644 --- a/lib/feature/detector/presentation/view/detect_view.dart +++ b/lib/feature/detector/presentation/view/detect_view.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:form_inputs/form_inputs.dart'; import 'package:gpt_detector/app/constants/string_constants.dart'; import 'package:gpt_detector/app/l10n/extensions/app_l10n_extensions.dart'; +import 'package:gpt_detector/app/theme/theme_constants.dart'; +import 'package:gpt_detector/app/widgets/gpt_elevated_button.dart'; import 'package:gpt_detector/core/extensions/context_extensions.dart'; import 'package:gpt_detector/core/utils/snackbar/snackbar_utils.dart'; import 'package:gpt_detector/feature/detector/data/model/detector/detector_model.dart'; @@ -22,6 +24,9 @@ class DetectView extends StatelessWidget { return Scaffold( appBar: AppBar( title: const Text(StringConstants.appName), + backgroundColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, actions: [ IconButton( onPressed: () => showDialog(context: context, builder: (context) => const GPTFAQDialog()), @@ -107,7 +112,7 @@ class _DetectViewBodyState extends State<_DetectViewBody> { state.convertToLocalizedString(context.l10n), textAlign: TextAlign.center, style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: ThemeConstants.fontWeightSemiBold, color: textColor, ), ), @@ -133,6 +138,7 @@ class _DetectViewBodyState extends State<_DetectViewBody> { right: 0, child: IconButton( icon: const Icon(Icons.clear), + color: context.colorScheme.primary, onPressed: () { _controller.clear(); context.read().clearTextPressed(); @@ -146,6 +152,7 @@ class _DetectViewBodyState extends State<_DetectViewBody> { children: [ IconButton( icon: const Icon(Icons.photo_library), + color: context.colorScheme.primary, onPressed: () async { // Control the gallery permission await context.read().checkGalleryPermission(); @@ -160,6 +167,7 @@ class _DetectViewBodyState extends State<_DetectViewBody> { ), IconButton( icon: const Icon(Icons.photo_camera), + color: context.colorScheme.primary, onPressed: () async { // Control the camera permission await context.read().checkCameraPermission(); @@ -223,16 +231,15 @@ class _DetectViewBodyState extends State<_DetectViewBody> { ), BlocBuilder( buildWhen: (previous, current) => - (previous.status.isValidated && !previous.status.isSubmissionInProgress) != - (current.status.isValidated && !current.status.isSubmissionInProgress), + previous.status.isSubmissionInProgress != current.status.isSubmissionInProgress, builder: (context, state) { - return ElevatedButton( - onPressed: state.status.isValidated && !state.status.isSubmissionInProgress - ? () => context.read().detectionRequested(text: _controller.text) - : null, - child: state.status.isSubmissionInProgress - ? const CircularProgressIndicator.adaptive() - : Text(context.l10n.analyzeText), + return GPTElevatedButton( + showingLoadingIndicator: state.status.isSubmissionInProgress, + text: context.l10n.analyzeText, + onPressed: () => context.read().detectionRequested( + context: context, + text: _controller.text, + ), ); }, ), diff --git a/lib/feature/onboarding/presentation/view/onboarding_view.dart b/lib/feature/onboarding/presentation/view/onboarding_view.dart index d49cad9..1f1e7e8 100644 --- a/lib/feature/onboarding/presentation/view/onboarding_view.dart +++ b/lib/feature/onboarding/presentation/view/onboarding_view.dart @@ -8,6 +8,7 @@ import 'package:gpt_detector/app/constants/duration_constants.dart'; import 'package:gpt_detector/app/constants/string_constants.dart'; import 'package:gpt_detector/app/l10n/extensions/app_l10n_extensions.dart'; import 'package:gpt_detector/app/router/app_router.dart'; +import 'package:gpt_detector/app/widgets/gpt_elevated_button.dart'; import 'package:gpt_detector/core/extensions/context_extensions.dart'; import 'package:gpt_detector/feature/detector/presentation/view/detect_view.dart'; import 'package:gpt_detector/feature/onboarding/presentation/cubit/onboarding_cubit.dart'; @@ -51,9 +52,10 @@ class _OnboardingViewBody extends StatelessWidget { ), ), SizedBox( - height: context.defaultValue, + height: context.highValue, ), Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Icon( @@ -61,6 +63,9 @@ class _OnboardingViewBody extends StatelessWidget { size: context.mediumValue, ), ), + SizedBox( + width: context.lowValue, + ), Expanded( flex: 5, child: Text( @@ -81,6 +86,9 @@ class _OnboardingViewBody extends StatelessWidget { size: context.mediumValue, ), ), + SizedBox( + width: context.lowValue, + ), Expanded( flex: 5, child: Text( @@ -94,13 +102,13 @@ class _OnboardingViewBody extends StatelessWidget { SizedBox( height: context.defaultValue, ), - ElevatedButton( + GPTElevatedButton( + text: context.l10n.getStarted, onPressed: () async { await context.read().completeOnboarding(); if (!context.mounted) return; unawaited(AppRouter.pushReplacement(context, const DetectView())); }, - child: Text(context.l10n.getStarted), ), ] .animate(interval: DurationConstants.ms250()) diff --git a/pubspec.yaml b/pubspec.yaml index c429555..5f075e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,10 +94,10 @@ flutter_launcher_icons: ios: true remove_alpha_ios: true min_sdk_android: 21 - image_path: "assets/icons/logo.png" + image_path: "assets/icons/app_icon/logo.png" adaptive_icon_background: "#673AB7" # Padding 8% - adaptive_icon_foreground: "assets/icons/logo_foreground.png" + adaptive_icon_foreground: "assets/icons/app_icon/logo_foreground.png" flutter_native_splash: android: true @@ -111,3 +111,9 @@ flutter: assets: - assets/icons/ + - assets/icons/app_icon/ + + fonts: + - family: Poppins + fonts: + - asset: assets/fonts/Poppins-Regular.ttf diff --git a/test/feature/detector/presentation/cubit/detector_cubit_test.dart b/test/feature/detector/presentation/cubit/detector_cubit_test.dart index 77be81d..3dd2243 100644 --- a/test/feature/detector/presentation/cubit/detector_cubit_test.dart +++ b/test/feature/detector/presentation/cubit/detector_cubit_test.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:form_inputs/form_inputs.dart'; import 'package:fpdart/fpdart.dart'; @@ -27,6 +28,8 @@ class MockHasGalleryPermissionUseCase extends Mock implements HasGalleryPermissi class MockDetectorEntity extends Mock implements DetectorEntity {} +class MockBuildContext extends Mock implements BuildContext {} + String generateRandomString(int len) { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; final rnd = Random(); @@ -50,6 +53,7 @@ void main() { late UserInputForm validInputForm; late String invalidUserInput; late UserInputForm invalidInputForm; + late BuildContext context; setUp(() { mockDetectUseCase = MockDetectUseCase(); @@ -69,6 +73,7 @@ void main() { validInputForm = UserInputForm.dirty(validUserInput); invalidUserInput = ''; invalidInputForm = UserInputForm.dirty(invalidUserInput); + context = MockBuildContext(); }); group('DetectorCubit.detectionRequested()', () { test("Initial value of the 'status' variable must be 'FormzStatus.pure' at start", () { @@ -97,7 +102,7 @@ void main() { ); }, build: () => detectorCubit, - act: (bloc) => bloc.detectionRequested(text: validUserInput), + act: (bloc) => bloc.detectionRequested(context: context, text: validUserInput), expect: () => [ detectorCubit.state.copyWith(status: FormzStatus.submissionInProgress, failure: null), detectorCubit.state.copyWith( @@ -115,7 +120,7 @@ void main() { ); }, build: () => detectorCubit, - act: (bloc) => bloc.detectionRequested(text: validUserInput), + act: (bloc) => bloc.detectionRequested(context: context, text: validUserInput), expect: () => [ detectorCubit.state.copyWith( status: FormzStatus.submissionInProgress,