diff --git a/lib/common/theme/app_style.dart b/lib/common/theme/app_style.dart index 822a346..246e087 100644 --- a/lib/common/theme/app_style.dart +++ b/lib/common/theme/app_style.dart @@ -7,21 +7,21 @@ class AppStyle { static TextStyle md = TextStyle(color: AppColor.textPrimary, fontSize: 14); - static TextStyle lg = TextStyle(color: AppColor.textPrimary, fontSize: 18); + static TextStyle lg = TextStyle(color: AppColor.textPrimary, fontSize: 16); - static TextStyle xl = TextStyle(color: AppColor.textPrimary, fontSize: 20); + static TextStyle xl = TextStyle(color: AppColor.textPrimary, fontSize: 18); - static TextStyle xxl = TextStyle(color: AppColor.textPrimary, fontSize: 22); + static TextStyle xxl = TextStyle(color: AppColor.textPrimary, fontSize: 20); - static TextStyle h6 = TextStyle(color: AppColor.textPrimary, fontSize: 24); + static TextStyle h6 = TextStyle(color: AppColor.textPrimary, fontSize: 22); - static TextStyle h5 = TextStyle(color: AppColor.textPrimary, fontSize: 26); + static TextStyle h5 = TextStyle(color: AppColor.textPrimary, fontSize: 24); - static TextStyle h4 = TextStyle(color: AppColor.textPrimary, fontSize: 28); + static TextStyle h4 = TextStyle(color: AppColor.textPrimary, fontSize: 26); - static TextStyle h3 = TextStyle(color: AppColor.textPrimary, fontSize: 30); + static TextStyle h3 = TextStyle(color: AppColor.textPrimary, fontSize: 28); - static TextStyle h2 = TextStyle(color: AppColor.textPrimary, fontSize: 32); + static TextStyle h2 = TextStyle(color: AppColor.textPrimary, fontSize: 30); - static TextStyle h1 = TextStyle(color: AppColor.textPrimary, fontSize: 34); + static TextStyle h1 = TextStyle(color: AppColor.textPrimary, fontSize: 32); } diff --git a/lib/common/theme/app_value.dart b/lib/common/theme/app_value.dart index 1a2d8d8..53ed1a6 100644 --- a/lib/common/theme/app_value.dart +++ b/lib/common/theme/app_value.dart @@ -2,6 +2,7 @@ part of 'theme.dart'; class AppValue { static const double padding = 16.0; + static const double margin = 16.0; static const double radius = 8.0; static const double elevation = 4.0; } diff --git a/lib/common/theme/theme.dart b/lib/common/theme/theme.dart index 4d69f35..0eb846f 100644 --- a/lib/common/theme/theme.dart +++ b/lib/common/theme/theme.dart @@ -1,11 +1,46 @@ import 'package:flutter/material.dart'; +import '../../presentation/components/assets/fonts.gen.dart'; + part 'app_color.dart'; part 'app_style.dart'; part 'app_value.dart'; class ThemeApp { static ThemeData get theme => ThemeData( - useMaterial3: true, - ); + useMaterial3: true, + scaffoldBackgroundColor: AppColor.background, + fontFamily: FontFamily.quicksand, + inputDecorationTheme: InputDecorationTheme( + hintStyle: AppStyle.md.copyWith(color: AppColor.textSecondary), + contentPadding: const EdgeInsets.symmetric(horizontal: AppValue.padding), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppValue.radius), + borderSide: const BorderSide(color: AppColor.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppValue.radius), + borderSide: const BorderSide(color: AppColor.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppValue.radius), + borderSide: const BorderSide(color: AppColor.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppValue.radius), + borderSide: const BorderSide(color: AppColor.error), + ), + filled: true, + fillColor: AppColor.backgroundLight, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppValue.radius), + ), + ), + ), + ); } diff --git a/lib/common/validator/validator.dart b/lib/common/validator/validator.dart new file mode 100644 index 0000000..63589f0 --- /dev/null +++ b/lib/common/validator/validator.dart @@ -0,0 +1,28 @@ +class AppValidator { + static String? validateEmail(String? value) { + if (value == null || value.isEmpty) { + return 'Email wajib diisi'; + } + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(value)) { + return 'Format email tidak valid'; + } + return null; + } + + static String? validatePassword(String? value) { + if (value == null || value.isEmpty) { + return 'Password wajib diisi'; + } + if (value.length < 8) { + return 'Password minimal 8 karakter'; + } + // if (!RegExp(r'[A-Z]').hasMatch(value)) { + // return 'Password harus mengandung huruf besar'; + // } + // if (!RegExp(r'[0-9]').hasMatch(value)) { + // return 'Password harus mengandung angka'; + // } + return null; + } +} diff --git a/lib/presentation/components/button/button.dart b/lib/presentation/components/button/button.dart new file mode 100644 index 0000000..881669f --- /dev/null +++ b/lib/presentation/components/button/button.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; + +import '../../../common/theme/theme.dart'; +import '../spacer/spacer.dart'; + +part 'elevated_button.dart'; diff --git a/lib/presentation/components/button/elevated_button.dart b/lib/presentation/components/button/elevated_button.dart new file mode 100644 index 0000000..b86a65f --- /dev/null +++ b/lib/presentation/components/button/elevated_button.dart @@ -0,0 +1,67 @@ +part of 'button.dart'; + +class AppElevatedButton extends StatelessWidget { + const AppElevatedButton({ + super.key, + required this.text, + required this.isLoading, + required this.onPressed, + this.height = 50, + }); + + final String text; + final bool isLoading; + final Function()? onPressed; + final double height; + + @override + Widget build(BuildContext context) { + return Container( + height: height, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: AppColor.primaryGradient), + borderRadius: BorderRadius.circular(AppValue.radius), + boxShadow: [ + BoxShadow( + color: AppColor.primaryWithOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 8), + ), + ], + ), + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: isLoading + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Loading', + style: AppStyle.lg.copyWith( + fontWeight: FontWeight.w600, + color: AppColor.white, + ), + ), + SpaceWidth(8), + SpinKitCircle(color: AppColor.white, size: 24.0), + ], + ) + : Text( + text, + style: AppStyle.lg.copyWith( + fontWeight: FontWeight.w600, + color: AppColor.white, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/components/field/field.dart b/lib/presentation/components/field/field.dart new file mode 100644 index 0000000..ed5287d --- /dev/null +++ b/lib/presentation/components/field/field.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icon.dart'; +import 'package:line_icons/line_icons.dart'; + +import '../../../common/theme/theme.dart'; +import '../spacer/spacer.dart'; + +part 'password_text_form_field.dart'; +part 'text_form_field.dart'; diff --git a/lib/presentation/components/field/password_text_form_field.dart b/lib/presentation/components/field/password_text_form_field.dart new file mode 100644 index 0000000..7da90f0 --- /dev/null +++ b/lib/presentation/components/field/password_text_form_field.dart @@ -0,0 +1,69 @@ +part of 'field.dart'; + +class AppPasswordTextFormField extends StatefulWidget { + const AppPasswordTextFormField({ + super.key, + this.controller, + required this.title, + this.hintText, + required this.prefixIcon, + this.validator, + }); + + final TextEditingController? controller; + final String title; + final String? hintText; + final IconData prefixIcon; + final String? Function(String?)? validator; + + @override + State createState() => + _AppPasswordTextFormFieldState(); +} + +class _AppPasswordTextFormFieldState extends State { + bool _obscurePassword = true; + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: AppStyle.lg.copyWith( + fontWeight: FontWeight.w600, + color: AppColor.textPrimary, + ), + ), + const SpaceHeight(8), + TextFormField( + controller: widget.controller, + keyboardType: TextInputType.emailAddress, + cursorColor: AppColor.primary, + obscureText: _obscurePassword, + style: AppStyle.md.copyWith(color: AppColor.textPrimary), + decoration: InputDecoration( + hintText: widget.hintText, + prefixIcon: LineIcon( + widget.prefixIcon, + color: AppColor.textSecondary, + ), + suffixIcon: IconButton( + padding: EdgeInsets.zero, + icon: Icon( + _obscurePassword ? LineIcons.eye : LineIcons.eyeSlash, + color: AppColor.textSecondary, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + validator: widget.validator, + ), + ], + ); + } +} diff --git a/lib/presentation/components/field/text_form_field.dart b/lib/presentation/components/field/text_form_field.dart new file mode 100644 index 0000000..2400c8e --- /dev/null +++ b/lib/presentation/components/field/text_form_field.dart @@ -0,0 +1,46 @@ +part of 'field.dart'; + +class AppTextFormField extends StatelessWidget { + const AppTextFormField({ + super.key, + this.controller, + required this.title, + this.hintText, + required this.prefixIcon, + this.validator, + }); + + final TextEditingController? controller; + final String title; + final String? hintText; + final IconData prefixIcon; + final String? Function(String?)? validator; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppStyle.lg.copyWith( + fontWeight: FontWeight.w600, + color: AppColor.textPrimary, + ), + ), + const SpaceHeight(8), + TextFormField( + controller: controller, + keyboardType: TextInputType.emailAddress, + cursorColor: AppColor.primary, + style: AppStyle.md.copyWith(color: AppColor.textPrimary), + decoration: InputDecoration( + hintText: hintText, + prefixIcon: LineIcon(prefixIcon, color: AppColor.textSecondary), + ), + validator: validator, + ), + ], + ); + } +} diff --git a/lib/presentation/components/spacer/spacer.dart b/lib/presentation/components/spacer/spacer.dart new file mode 100644 index 0000000..3732df0 --- /dev/null +++ b/lib/presentation/components/spacer/spacer.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class SpaceHeight extends StatelessWidget { + const SpaceHeight(this.height, {super.key}); + + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox(height: height); + } +} + +class SpaceWidth extends StatelessWidget { + const SpaceWidth(this.width, {super.key}); + + final double width; + + @override + Widget build(BuildContext context) { + return SizedBox(width: width); + } +} diff --git a/lib/presentation/pages/auth/login/login_page.dart b/lib/presentation/pages/auth/login/login_page.dart new file mode 100644 index 0000000..7495a14 --- /dev/null +++ b/lib/presentation/pages/auth/login/login_page.dart @@ -0,0 +1,217 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; + +import '../../../../common/theme/theme.dart'; +import '../../../components/button/button.dart'; +import '../../../components/spacer/spacer.dart'; +import 'widgets/email_field.dart'; +import 'widgets/password_field.dart'; + +@RoutePage() +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State with TickerProviderStateMixin { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _isLoading = false; + + late AnimationController _fadeController; + late AnimationController _slideController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + + _fadeController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + + _slideController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _fadeController, curve: Curves.easeInOut), + ); + + _slideAnimation = + Tween(begin: const Offset(0, 0.3), end: Offset.zero).animate( + CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic), + ); + _emailController.text = 'test@example.com'; + _passwordController.text = 'password'; + _fadeController.forward(); + _slideController.forward(); + } + + @override + void dispose() { + _fadeController.dispose(); + _slideController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (_formKey.currentState!.validate()) { + setState(() { + _isLoading = true; + }); + + // Simulasi proses login + await Future.delayed(const Duration(seconds: 2)); + + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppColor.primaryGradient, + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: AppValue.padding), + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLogo(), + SpaceHeight(48), + _buildLoginCard(), + SpaceHeight(24), + _buildFooter(), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildLogo() { + return Column( + children: [ + Text( + 'Welcome Back', + style: AppStyle.h1.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.white, + ), + ), + const SpaceHeight(8), + Text( + 'Sign in to your account', + style: AppStyle.lg.copyWith(color: AppColor.textLight), + ), + ], + ); + } + + Widget _buildLoginCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + decoration: BoxDecoration( + color: AppColor.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: AppColor.black.withOpacity(0.1), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LoginEmailField(controller: _emailController), + const SpaceHeight(24), + LoginPasswordField(controller: _passwordController), + const SpaceHeight(16), + _buildForgetPassword(), + const SpaceHeight(32), + _buildLoginButton(), + ], + ), + ), + ); + } + + Widget _buildForgetPassword() { + return Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () {}, + child: Text( + 'Forgot Password?', + style: AppStyle.md.copyWith( + color: AppColor.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + Widget _buildLoginButton() { + return AppElevatedButton( + text: 'Sign In', + isLoading: _isLoading, + onPressed: _isLoading ? null : _handleLogin, + ); + } + + Widget _buildFooter() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Don't have an account? ", + style: AppStyle.md.copyWith(color: AppColor.textLight), + ), + GestureDetector( + onTap: () {}, + child: Text( + 'Sign Up', + style: AppStyle.md.copyWith( + color: AppColor.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/pages/auth/login/widgets/email_field.dart b/lib/presentation/pages/auth/login/widgets/email_field.dart new file mode 100644 index 0000000..5d96426 --- /dev/null +++ b/lib/presentation/pages/auth/login/widgets/email_field.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; + +import '../../../../../common/validator/validator.dart'; +import '../../../../components/field/field.dart'; + +class LoginEmailField extends StatelessWidget { + final TextEditingController? controller; + const LoginEmailField({super.key, this.controller}); + + @override + Widget build(BuildContext context) { + return AppTextFormField( + title: 'Email', + hintText: 'Enter your email', + prefixIcon: LineIcons.envelope, + validator: AppValidator.validateEmail, + controller: controller, + ); + } +} diff --git a/lib/presentation/pages/auth/login/widgets/password_field.dart b/lib/presentation/pages/auth/login/widgets/password_field.dart new file mode 100644 index 0000000..df11f0a --- /dev/null +++ b/lib/presentation/pages/auth/login/widgets/password_field.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; + +import '../../../../../common/validator/validator.dart'; +import '../../../../components/field/field.dart'; + +class LoginPasswordField extends StatelessWidget { + final TextEditingController controller; + const LoginPasswordField({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return AppPasswordTextFormField( + title: 'Password', + prefixIcon: LineIcons.lock, + hintText: 'Enter your password', + validator: AppValidator.validatePassword, + controller: controller, + ); + } +} diff --git a/lib/presentation/pages/splash_page.dart b/lib/presentation/pages/splash_page.dart index f838bf4..a2a8693 100644 --- a/lib/presentation/pages/splash_page.dart +++ b/lib/presentation/pages/splash_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../common/theme/theme.dart'; import '../components/assets/assets.gen.dart'; +import '../router/app_router.gr.dart'; @RoutePage() class SplashPage extends StatefulWidget { @@ -79,7 +80,7 @@ class _SplashPageState extends State with TickerProviderStateMixin { void _navigateToHome() { // Uncomment dan sesuaikan dengan route yang ada - // context.router.replace(const HomeRoute()); + context.router.replace(const LoginRoute()); } @override diff --git a/lib/presentation/router/app_router.dart b/lib/presentation/router/app_router.dart index 4c75faf..7affb17 100644 --- a/lib/presentation/router/app_router.dart +++ b/lib/presentation/router/app_router.dart @@ -5,7 +5,10 @@ import 'app_router.gr.dart'; class AppRouter extends RootStackRouter { @override List get routes => [ - // Splash - AutoRoute(page: SplashRoute.page, initial: true), - ]; + // Splash + AutoRoute(page: SplashRoute.page, initial: true), + + // Auth + AutoRoute(page: LoginRoute.page), + ]; } diff --git a/lib/presentation/router/app_router.gr.dart b/lib/presentation/router/app_router.gr.dart index 54491c3..4d0bb28 100644 --- a/lib/presentation/router/app_router.gr.dart +++ b/lib/presentation/router/app_router.gr.dart @@ -9,22 +9,40 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:apskel_owner_flutter/presentation/pages/splash_page.dart' +import 'package:apskel_owner_flutter/presentation/pages/auth/login/login_page.dart' as _i1; -import 'package:auto_route/auto_route.dart' as _i2; +import 'package:apskel_owner_flutter/presentation/pages/splash_page.dart' + as _i2; +import 'package:auto_route/auto_route.dart' as _i3; /// generated route for -/// [_i1.SplashPage] -class SplashRoute extends _i2.PageRouteInfo { - const SplashRoute({List<_i2.PageRouteInfo>? children}) +/// [_i1.LoginPage] +class LoginRoute extends _i3.PageRouteInfo { + const LoginRoute({List<_i3.PageRouteInfo>? children}) + : super(LoginRoute.name, initialChildren: children); + + static const String name = 'LoginRoute'; + + static _i3.PageInfo page = _i3.PageInfo( + name, + builder: (data) { + return const _i1.LoginPage(); + }, + ); +} + +/// generated route for +/// [_i2.SplashPage] +class SplashRoute extends _i3.PageRouteInfo { + const SplashRoute({List<_i3.PageRouteInfo>? children}) : super(SplashRoute.name, initialChildren: children); static const String name = 'SplashRoute'; - static _i2.PageInfo page = _i2.PageInfo( + static _i3.PageInfo page = _i3.PageInfo( name, builder: (data) { - return const _i1.SplashPage(); + return const _i2.SplashPage(); }, ); } diff --git a/pubspec.lock b/pubspec.lock index 01684df..0678b10 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -366,6 +366,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_spinkit: + dependency: "direct main" + description: + name: flutter_spinkit + sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f" + url: "https://pub.dev" + source: hosted + version: "5.2.2" flutter_svg: dependency: "direct main" description: @@ -560,6 +568,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + line_icons: + dependency: "direct main" + description: + name: line_icons + sha256: "249d781d922f5437ac763d9c8f5a02cf5b499a6dc3f85e4b92e074cff0a932ab" + url: "https://pub.dev" + source: hosted + version: "2.0.3" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c73810e..808f716 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: json_annotation: ^4.9.0 shared_preferences: ^2.5.3 awesome_dio_interceptor: ^1.3.0 + line_icons: ^2.0.3 + flutter_spinkit: ^5.2.2 dev_dependencies: flutter_test: