diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aeb9f36..8950d88 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -47,4 +47,5 @@ java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } -} \ No newline at end of file +} +apply(plugin = "com.google.gms.google-services") diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..e749f04 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,30 @@ +{ + "project_info": { + "project_number": "835298460246", + "firebase_url": "https://vector-7729b-default-rtdb.asia-southeast1.firebasedatabase.app", + "project_id": "vector-7729b", + "storage_bucket": "vector-7729b.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:835298460246:android:6d4a4f9da1b2a5a69ab1f7", + "android_client_info": { + "package_name": "com.example.vector" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyA2414zGiQceTrFLSkyuympsmP8Qp2FrOs" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..14c3ed2 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("com.google.gms.google-services") version "4.4.2" apply false +} + allprojects { repositories { google() diff --git a/assets/images/welcome_illustration.png b/assets/images/welcome_illustration.png new file mode 100644 index 0000000..a0bfd42 Binary files /dev/null and b/assets/images/welcome_illustration.png differ diff --git a/lib/core/config/env_config.dart b/lib/core/config/env_config.dart new file mode 100644 index 0000000..f128436 --- /dev/null +++ b/lib/core/config/env_config.dart @@ -0,0 +1,8 @@ +class EnvConfig { + static const String firebaseDatabaseUrl = String.fromEnvironment( + 'FIREBASE_DB_URL', + defaultValue: '', + ); + + static bool get isConfigured => firebaseDatabaseUrl.isNotEmpty; +} diff --git a/lib/core/constants.dart b/lib/core/constants.dart index bdf3dcf..b023c54 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -1 +1,53 @@ -// Add your API keys/colors here +import 'config/env_config.dart'; + +// App Info +const String appName = 'Vector'; + +// Firebase Database +const String firebaseDatabaseUrl = EnvConfig.firebaseDatabaseUrl; + +// Database Paths +const String usersPath = 'users'; +const String onboardingPath = 'onboarding'; +const String connectionTestPath = '_connection_test'; +const String connectedInfoPath = '.info/connected'; + +// Colors +const int colorBackground = 0xFF0B0B0F; +const int colorPrimary = 0xFF693298; +const int colorPrimaryDark = 0xFF562A7D; +const int colorSecondary = 0xFF2A2438; +const int colorAccent = 0xFFEE94FE; +const int colorTextPrimary = 0xFFF5F5F5; +const int colorTextSecondary = 0xFFFFFFFF; + +// Sizes +const double paddingSmall = 8.0; +const double paddingMedium = 16.0; +const double paddingLarge = 24.0; +const double borderRadius = 12.0; +const double borderRadiusButton = 26.0; +const double buttonHeight = 52.0; +const double iconSizeSmall = 16.0; +const double iconSizeMedium = 24.0; +const double iconSizeLarge = 32.0; + +// Text Sizes +const double textSizeSmall = 14.0; +const double textSizeMedium = 18.0; +const double textSizeLarge = 24.0; +const double textSizeXLarge = 28.0; + +// Age Picker +const int minAge = 13; +const int maxAge = 120; +const double agePickerHeight = 192.6; +const double agePickerItemExtent = 50.0; + +// Height Picker (in cm) +const int minHeight = 100; +const int maxHeight = 250; + +// Weight Picker (in kg) +const int minWeight = 30; +const int maxWeight = 200; diff --git a/lib/core/services/auth_service.dart b/lib/core/services/auth_service.dart index e69de29..1670b6d 100644 --- a/lib/core/services/auth_service.dart +++ b/lib/core/services/auth_service.dart @@ -0,0 +1,41 @@ +import 'package:firebase_auth/firebase_auth.dart'; + +class AuthService { + final FirebaseAuth _auth = FirebaseAuth.instance; + + // REGISTER + Future register({ + required String email, + required String password, + }) async { + final userCredential = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + return userCredential.user; + } + + // LOGIN + Future login({ + required String email, + required String password, + }) async { + final userCredential = await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + return userCredential.user; + } + + // FORGOT PASSWORD + Future resetPassword({ + required String email, + }) async { + await _auth.sendPasswordResetEmail(email: email); + } + + // LOGOUT (future use) + Future logout() async { + await _auth.signOut(); + } +} diff --git a/lib/core/utils/firebase_test_util.dart b/lib/core/utils/firebase_test_util.dart new file mode 100644 index 0000000..7a8a473 --- /dev/null +++ b/lib/core/utils/firebase_test_util.dart @@ -0,0 +1,119 @@ +import 'package:flutter/foundation.dart'; +import '../services/firebase_database_service.dart'; +import '../../models/onboarding_data_model.dart'; + +class FirebaseTestUtil { + static final FirebaseDatabaseService _service = FirebaseDatabaseService(); + + static Future runAllTests() async { + debugPrint('═══════════════════════════════════════════'); + debugPrint('🔥 Firebase Realtime Database Test Suite'); + debugPrint('═══════════════════════════════════════════\n'); + + await _testConnection(); + await _testWriteAndRead(); + await _testUpdate(); + await _testDelete(); + + debugPrint('═══════════════════════════════════════════'); + debugPrint('✅ All tests completed'); + debugPrint('═══════════════════════════════════════════'); + } + + static Future _testConnection() async { + debugPrint('📡 Test 1: Connection Test'); + debugPrint('───────────────────────────────────────────'); + + final result = await _service.testConnection(); + + if (result.success) { + debugPrint(' ✅ ${result.message}'); + } else { + debugPrint(' ❌ ${result.message}'); + } + debugPrint(''); + } + + static Future _testWriteAndRead() async { + debugPrint('📝 Test 2: Write and Read'); + debugPrint('───────────────────────────────────────────'); + + const testUserId = 'test_user_001'; + final testData = OnboardingDataModel( + gender: Gender.male, + age: 25, + height: 175, + heightUnit: 'cm', + weight: 70, + weightUnit: 'kg', + goals: [FitnessGoal.muscleGain, FitnessGoal.betterEndurance], + ); + + try { + await _service.saveOnboardingData(testUserId, testData); + debugPrint(' ✅ Write successful'); + + final readData = await _service.getOnboardingData(testUserId); + if (readData != null) { + debugPrint(' ✅ Read successful'); + debugPrint( + ' 📄 Data: Gender=${readData.gender?.name}, Age=${readData.age}', + ); + debugPrint(' 📄 Height=${readData.height}${readData.heightUnit}'); + debugPrint(' 📄 Weight=${readData.weight}${readData.weightUnit}'); + debugPrint( + ' 📄 Goals=${readData.goals?.map((g) => g.name).join(", ")}', + ); + } else { + debugPrint(' ❌ Read failed: No data returned'); + } + } catch (e) { + debugPrint(' ❌ Error: $e'); + } + debugPrint(''); + } + + static Future _testUpdate() async { + debugPrint('🔄 Test 3: Update'); + debugPrint('───────────────────────────────────────────'); + + const testUserId = 'test_user_001'; + + try { + await _service.updateOnboardingField(testUserId, {'age': 26}); + debugPrint(' ✅ Update successful'); + + final updated = await _service.getOnboardingData(testUserId); + if (updated?.age == 26) { + debugPrint(' ✅ Verified: Age updated to ${updated?.age}'); + } else { + debugPrint(' ❌ Update verification failed'); + } + } catch (e) { + debugPrint(' ❌ Error: $e'); + } + debugPrint(''); + } + + static Future _testDelete() async { + debugPrint('🗑️ Test 4: Delete'); + debugPrint('───────────────────────────────────────────'); + + const testUserId = 'test_user_001'; + + try { + await _service.deleteOnboardingData(testUserId); + debugPrint(' ✅ Delete successful'); + + final deleted = await _service.getOnboardingData(testUserId); + if (deleted == null) { + debugPrint(' ✅ Verified: Data removed'); + } else { + debugPrint(' ❌ Delete verification failed'); + } + } catch (e) { + debugPrint(' ❌ Error: $e'); + } + debugPrint(''); + } +} diff --git a/lib/main.dart b/lib/main.dart index 2a2f8d8..63962e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,28 +1,56 @@ +// import 'package:flutter/material.dart'; +// import 'package:provider/provider.dart'; + +// import 'view_models/home_view_model.dart'; + +// void main() { +// runApp(const MyApp()); +// } + +// class MyApp extends StatelessWidget { +// const MyApp({super.key}); + +// @override +// Widget build(BuildContext context) { +// return MultiProvider( +// providers: [ChangeNotifierProvider(create: (_) => HomeViewModel())], +// child: MaterialApp( +// title: 'Vector', +// debugShowCheckedModeBanner: false, +// theme: ThemeData( +// colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), +// useMaterial3: true, +// ), +// home: const Scaffold(body: Center(child: Text('Vector'))), +// ), +// ); +// } +// } + import 'package:flutter/material.dart'; +import 'views/welcome_view.dart'; import 'package:provider/provider.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'view_models/auth_view_model.dart'; -import 'view_models/home_view_model.dart'; - -void main() { - runApp(const MyApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(); + runApp(const VectorApp()); } - -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class VectorApp extends StatelessWidget { + const VectorApp({super.key}); @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ChangeNotifierProvider(create: (_) => HomeViewModel())], - child: MaterialApp( - title: 'Vector', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const Scaffold(body: Center(child: Text('Vector'))), - ), - ); + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthViewModel()), + ], + child: const MaterialApp( + debugShowCheckedModeBanner: false, + home: WelcomeView(), + ), +); } } diff --git a/lib/models/forget_password_model.dart b/lib/models/forget_password_model.dart new file mode 100644 index 0000000..744740d --- /dev/null +++ b/lib/models/forget_password_model.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; +import '../models/login_model.dart'; + +class ForgetPasswordPage extends StatefulWidget { + const ForgetPasswordPage({super.key}); + + @override + State createState() => _ForgetPasswordPageState(); +} + +class _ForgetPasswordPageState extends State { + final TextEditingController _emailController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final authVM = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFF0B0B0F), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: InkWell( + onTap: () => Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const LoginView()), + ), + child: const Text( + ' createState() => _LoginViewState(); +} + +class _LoginViewState extends State { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool rememberMe = true; + + @override + Widget build(BuildContext context) { + final authVM = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFF0B0B0F), + body: SafeArea( + child: SingleChildScrollView( + child: Center( + child: Column( + children: [ + const SizedBox(height: 60), + + const Text( + 'Welcome Back!', + style: TextStyle( + color: Colors.white, + fontSize: 29, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + ), + ), + + const SizedBox(height: 55), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Column( + children: [ + const Text( + '"Discipline is the bridge between goals and accomplishment" - Jim Rohn', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: 'Poppins', + ), + ), + + const SizedBox(height: 70), + + _label('Email'), + _input( + controller: _emailController, + hint: 'user@email.com', + ), + + const SizedBox(height: 20), + + _label('Password'), + _input( + controller: _passwordController, + hint: 'password', + isPassword: true, + ), + + const SizedBox(height: 15), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + onTap: () => setState(() => rememberMe = !rememberMe), + child: Row( + children: [ + Icon( + rememberMe + ? Icons.check_box + : Icons.check_box_outline_blank, + color: Colors.white, + ), + const SizedBox(width: 6), + const Text( + 'Remember me', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ForgetPasswordPage(), + ), + ); + }, + child: const Text( + 'Forgot Password?', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + + const SizedBox(height: 40), + + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF693298), + fixedSize: const Size(244, 63), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + ), + onPressed: authVM.isLoading + ? null + : () async { + await authVM.login( + email: _emailController.text.trim(), + password: + _passwordController.text.trim(), + ); + + if (authVM.error == null) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const AgePage(), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(authVM.error!), + ), + ); + } + }, + child: authVM.isLoading + ? const CircularProgressIndicator( + color: Colors.white, + ) + : const Text( + 'Log-in', + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontFamily: 'Poppins', + ), + ), + ), + + const SizedBox(height: 20), + + InkWell( + onTap: () => Navigator.pop(context), + child: const Text( + "Don't have an account ? Sign Up !", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: 'Poppins', + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _label(String text) => Align( + alignment: Alignment.centerLeft, + child: Text( + text, + style: const TextStyle( + fontSize: 20, + color: Colors.white, + fontFamily: 'Poppins', + ), + ), + ); + + Widget _input({ + required TextEditingController controller, + required String hint, + bool isPassword = false, + }) { + return TextField( + controller: controller, + obscureText: isPassword, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: const Color(0xFF2A2438), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ); + } +} diff --git a/lib/models/new_password_model.dart b/lib/models/new_password_model.dart new file mode 100644 index 0000000..80bdca2 --- /dev/null +++ b/lib/models/new_password_model.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import '../Models/login_model.dart'; +import '../view_models/new_password_view_model.dart'; + +class NewPasswordPage extends StatefulWidget { + const NewPasswordPage({super.key}); + + @override + State createState() => _NewPasswordPageState(); +} + +class _NewPasswordPageState extends State { + final NewPasswordViewModel viewModel = NewPasswordViewModel(); + + @override + + + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0B0B0F), + body: SafeArea( + child: Column( + children: [ + + // Back text + Align( + alignment: Alignment.centerLeft, + child: InkWell( + onTap: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => const LoginView(), + ), + ); + }, + child: const Text( + ' const LoginView(), + ), + ); + }, + child: const Text( + 'Confirm', + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle( + color: Color(0xFFAAA7AF), + fontSize: 15, + fontFamily: 'Poppins', + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: const Color(0xFF2A2438), + contentPadding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 20, + ), + ); + } +} diff --git a/lib/models/onboarding_data_model.dart b/lib/models/onboarding_data_model.dart new file mode 100644 index 0000000..ec81143 --- /dev/null +++ b/lib/models/onboarding_data_model.dart @@ -0,0 +1,101 @@ +enum Gender { male, female, others } + +enum FitnessGoal { gainWeight, loseWeight, muscleGain, betterEndurance, others } + +class OnboardingDataModel { + final String? userId; + final Gender? gender; + final int? age; + final int? height; + final String? heightUnit; + final int? weight; + final String? weightUnit; + final List? goals; + final DateTime? createdAt; + final DateTime? updatedAt; + + OnboardingDataModel({ + this.userId, + this.gender, + this.age, + this.height, + this.heightUnit = 'cm', + this.weight, + this.weightUnit = 'kg', + this.goals, + this.createdAt, + this.updatedAt, + }); + + Map toJson() { + return { + 'userId': userId, + 'gender': gender?.name, + 'age': age, + 'height': height, + 'heightUnit': heightUnit, + 'weight': weight, + 'weightUnit': weightUnit, + 'goals': goals?.map((g) => g.name).toList(), + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } + + factory OnboardingDataModel.fromJson(Map json) { + return OnboardingDataModel( + userId: json['userId'] as String?, + gender: json['gender'] != null + ? Gender.values.firstWhere( + (g) => g.name == json['gender'], + orElse: () => Gender.others, + ) + : null, + age: json['age'] as int?, + height: json['height'] as int?, + heightUnit: json['heightUnit'] as String? ?? 'cm', + weight: json['weight'] as int?, + weightUnit: json['weightUnit'] as String? ?? 'kg', + goals: (json['goals'] as List?) + ?.map( + (g) => FitnessGoal.values.firstWhere( + (goal) => goal.name == g, + orElse: () => FitnessGoal.others, + ), + ) + .toList(), + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + OnboardingDataModel copyWith({ + String? userId, + Gender? gender, + int? age, + int? height, + String? heightUnit, + int? weight, + String? weightUnit, + List? goals, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return OnboardingDataModel( + userId: userId ?? this.userId, + gender: gender ?? this.gender, + age: age ?? this.age, + height: height ?? this.height, + heightUnit: heightUnit ?? this.heightUnit, + weight: weight ?? this.weight, + weightUnit: weightUnit ?? this.weightUnit, + goals: goals ?? this.goals, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/models/verification_page_model.dart b/lib/models/verification_page_model.dart new file mode 100644 index 0000000..4946227 --- /dev/null +++ b/lib/models/verification_page_model.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import '../Models/forget_password_model.dart'; +import '../Models/new_password_model.dart'; +import '../view_models/verification_page_view_model.dart'; +import 'package:pinput/pinput.dart'; + +class VerificationPage extends StatefulWidget { + const VerificationPage({super.key}); + + @override + State createState() => _VerificationPageState(); +} + +class _VerificationPageState extends State { + final VerificationViewModel viewModel = VerificationViewModel(); + @override + Widget build(BuildContext context) { + final defaultPinTheme = PinTheme( + width: 35, + height: 45, + textStyle: const TextStyle( + fontSize: 32, + color: Colors.white, + fontWeight: FontWeight.w400, + fontFamily: 'Poppins', + ), + decoration: BoxDecoration( + color: const Color(0xFF2A2438), + borderRadius: BorderRadius.circular(12), + ), + ); + + return Scaffold( + backgroundColor: const Color(0xFF0B0B0F), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child:InkWell( + onTap: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => const ForgetPasswordPage(), + ), + ); + }, + child: const Text( + ' const NewPasswordPage(), + ), + ); + }, + child: const Text( + 'verify', + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + ), + ), + ), + + + ], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/view_models/auth_view_model.dart b/lib/view_models/auth_view_model.dart new file mode 100644 index 0000000..b4cd00d --- /dev/null +++ b/lib/view_models/auth_view_model.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import '../core/services/auth_service.dart'; + +class AuthViewModel extends ChangeNotifier { + final AuthService _authService = AuthService(); + + bool _isLoading = false; + String? _error; + + bool get isLoading => _isLoading; + String? get error => _error; + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + void _setError(String? message) { + _error = message; + notifyListeners(); + } + + // REGISTER + Future register({ + required String email, + required String password, + }) async { + try { + _setLoading(true); + _setError(null); + await _authService.register(email: email, password: password); + } catch (e) { + _setError(e.toString()); + } finally { + _setLoading(false); + } + } + + // LOGIN + Future login({ + required String email, + required String password, + }) async { + try { + _setLoading(true); + _setError(null); + await _authService.login(email: email, password: password); + } catch (e) { + _setError(e.toString()); + } finally { + _setLoading(false); + } + } + + // FORGOT PASSWORD + Future resetPassword({ + required String email, + }) async { + try { + _setLoading(true); + _setError(null); + await _authService.resetPassword(email: email); + } catch (e) { + _setError(e.toString()); + } finally { + _setLoading(false); + } + } +} diff --git a/lib/view_models/home_view_model.dart b/lib/view_models/home_view_model.dart index 8cafd5f..ff5cbf0 100644 --- a/lib/view_models/home_view_model.dart +++ b/lib/view_models/home_view_model.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; + import 'package:flutter/foundation.dart'; class HomeViewModel extends ChangeNotifier { bool _isLoading = false; diff --git a/lib/view_models/onboarding_view_model.dart b/lib/view_models/onboarding_view_model.dart new file mode 100644 index 0000000..6c8efae --- /dev/null +++ b/lib/view_models/onboarding_view_model.dart @@ -0,0 +1,174 @@ +import 'package:flutter/foundation.dart'; +import '../core/services/firebase_database_service.dart'; +import '../models/onboarding_data_model.dart'; + +enum OnboardingError { saveFailed, loadFailed, networkError } + +class OnboardingViewModel extends ChangeNotifier { + final FirebaseDatabaseService _db = FirebaseDatabaseService(); + + Gender? _gender; + int? _age; + int? _height; + String _heightUnit = 'cm'; + int? _weight; + String _weightUnit = 'kg'; + List _goals = []; + + bool _isSaving = false; + OnboardingError? _errorType; + + Gender? get gender => _gender; + int? get age => _age; + int? get height => _height; + String get heightUnit => _heightUnit; + int? get weight => _weight; + String get weightUnit => _weightUnit; + List get goals => List.unmodifiable(_goals); + bool get isSaving => _isSaving; + OnboardingError? get errorType => _errorType; + + bool get hasBasicInfo => _gender != null && _age != null; + bool get hasPhysicalInfo => _height != null && _weight != null; + bool get isComplete => hasBasicInfo && hasPhysicalInfo && _goals.isNotEmpty; + + String? get errorMessage { + if (_errorType == null) return null; + switch (_errorType!) { + case OnboardingError.saveFailed: + return 'Could not save your data. Please try again.'; + case OnboardingError.loadFailed: + return 'Could not load your profile.'; + case OnboardingError.networkError: + return 'Network error. Check your connection.'; + } + } + + void setGender(Gender gender) { + if (_gender == gender) return; + _gender = gender; + _clearError(); + notifyListeners(); + } + + void setAge(int age) { + if (!_isValidAge(age)) return; + _age = age; + notifyListeners(); + } + + void setHeight(int height, {String? unit}) { + _height = height; + if (unit != null) _heightUnit = unit; + notifyListeners(); + } + + void setWeight(int weight, {String? unit}) { + _weight = weight; + if (unit != null) _weightUnit = unit; + notifyListeners(); + } + + void toggleGoal(FitnessGoal goal) { + _goals.contains(goal) ? _goals.remove(goal) : _goals.add(goal); + notifyListeners(); + } + + bool _isValidAge(int age) => age >= 13 && age <= 120; + + String? validateStep(int step) { + switch (step) { + case 1: + return _gender == null ? 'Please select your gender' : null; + case 2: + if (_age == null) return 'Please enter your age'; + if (!_isValidAge(_age!)) return 'Age must be between 13 and 120'; + return null; + case 3: + return _height == null ? 'Please enter your height' : null; + case 4: + return _weight == null ? 'Please enter your weight' : null; + case 5: + return _goals.isEmpty ? 'Select at least one goal' : null; + default: + return null; + } + } + + Future save(String userId) async { + if (!isComplete) return false; + + _isSaving = true; + _clearError(); + notifyListeners(); + + try { + final data = OnboardingDataModel( + gender: _gender, + age: _age, + height: _height, + heightUnit: _heightUnit, + weight: _weight, + weightUnit: _weightUnit, + goals: _goals, + ); + + await _db.saveOnboardingData(userId, data); + _isSaving = false; + notifyListeners(); + return true; + } catch (e) { + _errorType = OnboardingError.saveFailed; + _isSaving = false; + notifyListeners(); + return false; + } + } + + Future load(String userId) async { + _isSaving = true; + notifyListeners(); + + try { + final data = await _db.getOnboardingData(userId); + if (data != null) { + _gender = data.gender; + _age = data.age; + _height = data.height; + _heightUnit = data.heightUnit ?? 'cm'; + _weight = data.weight; + _weightUnit = data.weightUnit ?? 'kg'; + _goals = data.goals ?? []; + } + _isSaving = false; + notifyListeners(); + return data != null; + } catch (e) { + _errorType = OnboardingError.loadFailed; + _isSaving = false; + notifyListeners(); + return false; + } + } + + void clearError() => _clearError(); + + void reset() { + _gender = null; + _age = null; + _height = null; + _heightUnit = 'cm'; + _weight = null; + _weightUnit = 'kg'; + _goals = []; + _clearError(); + notifyListeners(); + } + + void _clearError() { + if (_errorType != null) { + _errorType = null; + notifyListeners(); + } + } +} diff --git a/lib/views/age.dart b/lib/views/age.dart new file mode 100644 index 0000000..0f8ae3b --- /dev/null +++ b/lib/views/age.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/constants.dart'; +import '../view_models/onboarding_view_model.dart'; +import 'height.dart'; + +class AgePage extends StatefulWidget { + const AgePage({super.key}); + + @override + State createState() => _AgePageState(); +} + +class _AgePageState extends State { + late int selectedAge; + + @override + void initState() { + super.initState(); + final vm = context.read(); + selectedAge = vm.age ?? minAge; + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + appBar: AppBar( + title: const Text( + 'What is your age?', + style: TextStyle(fontSize: textSizeLarge), + ), + centerTitle: true, + foregroundColor: const Color(colorTextSecondary), + backgroundColor: const Color(colorBackground), + ), + body: Column( + children: [ + const Spacer(), + if (vm.errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: paddingLarge), + child: Text( + vm.errorMessage!, + style: const TextStyle( + color: Colors.redAccent, + fontSize: textSizeSmall, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: paddingMedium), + Container( + height: 70, + width: 170, + padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 16), + decoration: BoxDecoration( + color: const Color(colorPrimaryDark), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Text( + '$selectedAge', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 28, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 20), + Container( + width: 220, + height: 66, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(colorPrimary), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Age', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 19.48, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(width: 8), + Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 24), + ], + ), + ), + const SizedBox(height: 20), + Container( + height: agePickerHeight, + width: 220, + color: const Color(colorSecondary), + child: ListWheelScrollView.useDelegate( + itemExtent: agePickerItemExtent, + diameterRatio: 2.0, + useMagnifier: true, + magnification: 1.2, + overAndUnderCenterOpacity: 0.4, + physics: const FixedExtentScrollPhysics(), + perspective: 0.003, + childDelegate: ListWheelChildBuilderDelegate( + builder: (context, index) { + final age = minAge + index; + return Center( + child: Text( + '$age', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 19.48, + color: (selectedAge == age) + // ignore: deprecated_member_use + ? Color(0xFFFFFFFF).withOpacity( + 0.6, + ) // 60% opacity for selected + // ignore: deprecated_member_use + : Color(colorTextSecondary).withOpacity(1.0), + ), + ), + ); + }, + childCount: maxAge - minAge + 1, + ), + onSelectedItemChanged: (index) { + setState(() { + selectedAge = minAge + index; + vm.setAge(selectedAge); + }); + }, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 16, + ), + label: const Text( + 'Back', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + TextButton( + onPressed: () { + final error = vm.validateStep(2); + if (error == null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const HeightPage(), + ), + ); + } + }, + child: const Row( + children: [ + Text( + 'Next', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + SizedBox(width: 4), + Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 16, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/gender.dart b/lib/views/gender.dart new file mode 100644 index 0000000..bed4ebb --- /dev/null +++ b/lib/views/gender.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/constants.dart'; +import '../view_models/onboarding_view_model.dart'; +import '../models/onboarding_data_model.dart'; +import 'age.dart'; + +class GenderPage extends StatefulWidget { + const GenderPage({super.key}); + + @override + State createState() => _GenderPageState(); +} + +class _GenderPageState extends State { + Gender? selectedGender; + + @override + void initState() { + super.initState(); + final vm = context.read(); + selectedGender = vm.gender; + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 50), + child: Column( + children: [ + if (vm.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: paddingMedium), + child: Text( + vm.errorMessage!, + style: const TextStyle( + color: Colors.redAccent, + fontSize: textSizeSmall, + ), + textAlign: TextAlign.center, + ), + ), + const Text( + 'How do you Identify?', + style: TextStyle( + color: Colors.white, + fontSize: textSizeLarge, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 40), + Container( + padding: const EdgeInsets.symmetric( + vertical: 30, + horizontal: 20, + ), + decoration: BoxDecoration( + color: const Color(colorSecondary), + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + _buildOption(Gender.male, Icons.male, "Male"), + const SizedBox(height: 20), + _buildOption(Gender.female, Icons.female, "Female"), + const SizedBox(height: 20), + _buildOption(Gender.others, Icons.transgender, "Others"), + ], + ), + ), + const SizedBox(height: 40), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: iconSizeSmall, + ), + label: const Text( + 'Back', + style: TextStyle( + color: Colors.white, + fontSize: textSizeMedium, + ), + ), + ), + TextButton( + onPressed: () { + final error = vm.validateStep(1); + if (error == null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AgePage(), + ), + ); + } + }, + child: const Row( + children: [ + Text( + 'Next', + style: TextStyle( + color: Colors.white, + fontSize: textSizeMedium, + ), + ), + SizedBox(width: 4), + Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: iconSizeSmall, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildOption(Gender gender, IconData icon, String label) { + bool isSelected = selectedGender == gender; + return GestureDetector( + onTap: () { + setState(() => selectedGender = gender); + final vm = context.read(); + vm.setGender(gender); + }, + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + color: isSelected + ? const Color(colorPrimary) + : const Color(colorPrimaryDark), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: Colors.white, size: 50), + const SizedBox(height: 10), + Text(label, style: const TextStyle(color: Colors.white)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/goals.dart b/lib/views/goals.dart new file mode 100644 index 0000000..097afa7 --- /dev/null +++ b/lib/views/goals.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/constants.dart'; +import '../view_models/onboarding_view_model.dart'; +import '../models/onboarding_data_model.dart'; + +class GoalsPage extends StatefulWidget { + const GoalsPage({super.key}); + + @override + State createState() => _GoalsPageState(); +} + +class _GoalsPageState extends State { + Set selectedGoals = {}; + + @override + void initState() { + super.initState(); + final vm = context.read(); + selectedGoals = Set.from(vm.goals); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: 64), + if (vm.errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: paddingLarge), + child: Text( + vm.errorMessage!, + style: const TextStyle( + color: Colors.redAccent, + fontSize: textSizeSmall, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: paddingMedium), + const Text( + "What are your Goals?", + style: TextStyle( + color: Colors.white, + fontSize: textSizeLarge, + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: 56), + Container( + height: 504, + width: 323, + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 22), + decoration: BoxDecoration( + color: const Color(colorSecondary).withOpacity(0.5), + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + Expanded( + child: _buildGoalTile( + vm, + FitnessGoal.gainWeight, + "Gain Weight", + ), + ), + Expanded( + child: _buildGoalTile( + vm, + FitnessGoal.loseWeight, + "Lose Weight", + ), + ), + Expanded( + child: _buildGoalTile( + vm, + FitnessGoal.muscleGain, + "Muscle Gain", + ), + ), + Expanded( + child: _buildGoalTile( + vm, + FitnessGoal.betterEndurance, + "Better Endurance", + ), + ), + Expanded( + child: _buildGoalTile(vm, FitnessGoal.others, "Others"), + ), + ], + ), + ), + + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: iconSizeSmall, + ), + label: const Text( + "Back", + style: TextStyle( + color: Colors.white, + fontSize: textSizeMedium, + ), + ), + ), + TextButton( + onPressed: () async { + final error = vm.validateStep(5); + if (error == null && vm.isComplete) { + // Save all onboarding data + final success = await vm.save( + 'user_temp_id', + ); // TODO: Use real user ID + if (success) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Onboarding completed successfully!', + ), + ), + ); + // TODO: Navigate to home screen + } + } + } + }, + child: Row( + children: [ + Text( + vm.isSaving ? "Saving..." : "Complete", + style: const TextStyle( + color: Colors.white, + fontSize: textSizeMedium, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: iconSizeSmall, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildGoalTile(OnboardingViewModel vm, FitnessGoal goal, String text) { + final isSelected = selectedGoals.contains(goal); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + selectedGoals.remove(goal); + } else { + selectedGoals.add(goal); + } + }); + vm.toggleGoal(goal); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: isSelected + ? const Color(colorPrimary) + : const Color(colorPrimaryDark), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + text, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 17.14, + fontWeight: FontWeight.w500, + ), + ), + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: const Color( + 0xFF261140, + ).withOpacity(isSelected ? 1.0 : 0.68), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: const Color( + 0xFF261140, + ).withOpacity(isSelected ? 1.0 : 0.68), + ), + ), + child: isSelected + ? const Icon(Icons.check, size: 16, color: Colors.white) + : null, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/height.dart b/lib/views/height.dart new file mode 100644 index 0000000..a432890 --- /dev/null +++ b/lib/views/height.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/constants.dart'; +import '../view_models/onboarding_view_model.dart'; +import 'weight.dart'; + +class HeightPage extends StatefulWidget { + const HeightPage({super.key}); + + @override + State createState() => _HeightPageState(); +} + +class _HeightPageState extends State { + late int selectedHeight; + String selectedUnit = 'cm'; + + @override + void initState() { + super.initState(); + final vm = context.read(); + selectedHeight = vm.height ?? minHeight; + selectedUnit = vm.heightUnit; + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + appBar: AppBar( + title: const Text( + 'What is your Height?', + style: TextStyle(fontSize: textSizeLarge), + ), + centerTitle: true, + foregroundColor: const Color(colorTextSecondary), + backgroundColor: const Color(colorBackground), + ), + body: Column( + children: [ + const Spacer(), + if (vm.errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: paddingLarge), + child: Text( + vm.errorMessage!, + style: const TextStyle( + color: Colors.redAccent, + fontSize: textSizeSmall, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: paddingMedium), + Container( + height: 70, + width: 170, + padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 16), + decoration: BoxDecoration( + color: const Color(colorPrimaryDark), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Text( + '$selectedHeight', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 28, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 20), + Container( + width: 220, + height: 66, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(colorPrimary), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + selectedUnit, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 19.48, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.keyboard_arrow_up, + color: Colors.white, + size: 24, + ), + ], + ), + ), + const SizedBox(height: 20), + Container( + height: agePickerHeight, + width: 220, + color: const Color(colorSecondary), + child: ListWheelScrollView.useDelegate( + itemExtent: agePickerItemExtent, + diameterRatio: 2.0, + useMagnifier: true, + magnification: 1.2, + overAndUnderCenterOpacity: 0.4, + physics: const FixedExtentScrollPhysics(), + perspective: 0.003, + childDelegate: ListWheelChildBuilderDelegate( + builder: (context, index) { + final height = minHeight + index; + return Center( + child: Text( + '$height', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 19.48, + color: (selectedHeight == height) + ? const Color(0xFFFFFFFF).withOpacity(0.6) + : const Color(colorTextSecondary).withOpacity(1.0), + ), + ), + ); + }, + childCount: maxHeight - minHeight + 1, + ), + onSelectedItemChanged: (index) { + setState(() { + selectedHeight = minHeight + index; + vm.setHeight(selectedHeight, unit: selectedUnit); + }); + }, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 16, + ), + label: const Text( + 'Back', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + TextButton( + onPressed: () { + final error = vm.validateStep(3); + if (error == null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const WeightPage(), + ), + ); + } + }, + child: const Row( + children: [ + Text( + 'Next', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + SizedBox(width: 4), + Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 16, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/register_view.dart b/lib/views/register_view.dart new file mode 100644 index 0000000..d1895bb --- /dev/null +++ b/lib/views/register_view.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; + +class RegisterView extends StatelessWidget { + RegisterView({super.key}); + + // Controllers + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final authVM = context.watch(); + + return Scaffold( + resizeToAvoidBottomInset: true, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF0F0F14), + Color(0xFF07070A), + ], + ), + ), + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 40), + + const Text( + 'Create your Account !', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: 30), + + const CircleAvatar( + radius: 45, + backgroundColor: Color(0xFFBDB6D0), + child: Icon( + Icons.person_outline, + size: 50, + color: Color(0xFF5C5470), + ), + ), + + const SizedBox(height: 40), + + _buildInput( + label: 'Email', + hint: 'user@mail.com', + controller: _emailController, + ), + + const SizedBox(height: 20), + + _buildInput( + label: 'Username', + hint: 'user@name10', + controller: _usernameController, + ), + + const SizedBox(height: 20), + + _buildInput( + label: 'Password', + hint: 'Password', + controller: _passwordController, + isPassword: true, + ), + + const SizedBox(height: 40), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF7B3FE4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + ), + onPressed: authVM.isLoading + ? null + : () { + authVM.register( + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + ); + }, + child: authVM.isLoading + ? const CircularProgressIndicator( + color: Colors.white, + ) + : const Text( + 'Create account', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 20), + + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text( + 'already have an account? Log in!', + style: TextStyle( + color: Colors.white54, + fontSize: 13, + ), + ), + ), + + const SizedBox(height: 20), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + // Reusable input field + Widget _buildInput({ + required String label, + required String hint, + required TextEditingController controller, + bool isPassword = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + obscureText: isPassword, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Colors.white38), + filled: true, + fillColor: const Color(0xFF2B2738), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/weight.dart b/lib/views/weight.dart new file mode 100644 index 0000000..d82ee96 --- /dev/null +++ b/lib/views/weight.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../core/constants.dart'; +import '../view_models/onboarding_view_model.dart'; +import 'goals.dart'; + +class WeightPage extends StatefulWidget { + const WeightPage({super.key}); + + @override + State createState() => _WeightPageState(); +} + +class _WeightPageState extends State { + late int selectedWeight; + String selectedUnit = 'kg'; + + @override + void initState() { + super.initState(); + final vm = context.read(); + selectedWeight = vm.weight ?? minWeight; + selectedUnit = vm.weightUnit; + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + appBar: AppBar( + title: const Text( + 'What is your Weight?', + style: TextStyle(fontSize: textSizeLarge), + ), + centerTitle: true, + foregroundColor: const Color(colorTextSecondary), + backgroundColor: const Color(colorBackground), + ), + body: Column( + children: [ + const Spacer(), + if (vm.errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: paddingLarge), + child: Text( + vm.errorMessage!, + style: const TextStyle( + color: Colors.redAccent, + fontSize: textSizeSmall, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: paddingMedium), + Container( + height: 70, + width: 170, + padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 16), + decoration: BoxDecoration( + color: const Color(colorPrimaryDark), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Text( + '$selectedWeight', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 28, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 20), + Container( + width: 220, + height: 66, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(colorPrimary), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + selectedUnit, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 19.48, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.keyboard_arrow_up, + color: Colors.white, + size: 24, + ), + ], + ), + ), + const SizedBox(height: 20), + Container( + height: agePickerHeight, + width: 220, + color: const Color(colorSecondary), + child: ListWheelScrollView.useDelegate( + itemExtent: agePickerItemExtent, + diameterRatio: 2.0, + useMagnifier: true, + magnification: 1.2, + overAndUnderCenterOpacity: 0.4, + physics: const FixedExtentScrollPhysics(), + perspective: 0.003, + childDelegate: ListWheelChildBuilderDelegate( + builder: (context, index) { + final weight = minWeight + index; + return Center( + child: Text( + '$weight', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 19.48, + color: (selectedWeight == weight) + ? const Color(0xFFFFFFFF).withOpacity(0.6) + : const Color(colorTextSecondary).withOpacity(1.0), + ), + ), + ); + }, + childCount: maxWeight - minWeight + 1, + ), + onSelectedItemChanged: (index) { + setState(() { + selectedWeight = minWeight + index; + vm.setWeight(selectedWeight, unit: selectedUnit); + }); + }, + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 16, + ), + label: const Text( + 'Back', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + TextButton( + onPressed: () { + final error = vm.validateStep(4); + if (error == null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GoalsPage(), + ), + ); + } + }, + child: const Row( + children: [ + Text( + 'Next', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + SizedBox(width: 4), + Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 16, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/welcome.dart b/lib/views/welcome.dart new file mode 100644 index 0000000..d0408be --- /dev/null +++ b/lib/views/welcome.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../core/constants.dart'; +import 'gender.dart'; + +class WelcomePage extends StatelessWidget { + const WelcomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(colorBackground), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: paddingLarge), + child: Column( + children: [ + const Spacer(), + + Image.asset( + 'assets/Group_111.png', + height: MediaQuery.of(context).size.height * 0.35, + ), + + const SizedBox(height: 32), + + const Text( + 'Energize your lives with', + style: TextStyle( + color: Color(colorTextPrimary), + fontSize: textSizeMedium, + ), + ), + + const SizedBox(height: 8), + + const Text( + 'Vector!', + style: TextStyle( + color: Color(colorAccent), + fontSize: textSizeXLarge, + fontWeight: FontWeight.bold, + ), + ), + + const Spacer(), + + SizedBox( + width: double.infinity, + height: buttonHeight, + child: ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GenderPage(), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(colorPrimary), + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadiusButton), + ), + ), + child: const Text( + 'Get Started', + style: TextStyle( + fontSize: textSizeMedium, + color: Color(colorTextSecondary), + ), + ), + ), + ), + + const SizedBox(height: 16), + + const Text( + 'Join us for a better lifestyle!', + style: TextStyle( + color: Colors.white70, + fontSize: textSizeSmall, + ), + ), + + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/welcome_view.dart b/lib/views/welcome_view.dart new file mode 100644 index 0000000..5624886 --- /dev/null +++ b/lib/views/welcome_view.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'register_view.dart'; + +class WelcomeView extends StatelessWidget { + const WelcomeView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF0F0F14), + Color(0xFF07070A), + ], + ), + ), + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Illustration + Image.asset( + 'assets/images/welcome_illustration.png', + height: 240, + ), + + const SizedBox(height: 40), + + // Text + const Text( + 'Energize your lives with', + style: TextStyle( + color: Colors.white, + fontSize: 20, + ), + ), + + const SizedBox(height: 8), + + const Text( + 'Vector !', + style: TextStyle( + color: Color(0xFF9B5CF6), + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 50), + + // Button + SizedBox( + width: 220, + height: 52, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF7B3FE4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + elevation: 8, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RegisterView(), + ), + ); + }, + child: const Text( + 'Get Started', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white + ), + ), + ), + ), + + const SizedBox(height: 40), + + const Text( + 'Join us for a better lifestyle!', + style: TextStyle( + color: Colors.white54, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..67059e4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,12 @@ import FlutterMacOS import Foundation +import firebase_auth +import firebase_core +import firebase_database func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 09321f1..6ba95ee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" async: dependency: transitive description: @@ -57,6 +65,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" + url: "https://pub.dev" + source: hosted + version: "7.7.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e + url: "https://pub.dev" + source: hosted + version: "5.15.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_database: + dependency: "direct main" + description: + name: firebase_database + sha256: "35b37c04307b99c5f746387ce03292531c3aa1de91facffbd9cff5e069a8b5fd" + url: "https://pub.dev" + source: hosted + version: "11.3.10" + firebase_database_platform_interface: + dependency: transitive + description: + name: firebase_database_platform_interface + sha256: "095342e96d94b486b8273afc6327f777d53b63a169bd4201e5153ee3b8210c11" + url: "https://pub.dev" + source: hosted + version: "0.2.6+10" + firebase_database_web: + dependency: transitive + description: + name: firebase_database_web + sha256: "05f9b871d97b3ca879937947d0728ea95294395e7ddd5685583e8662be99eb16" + url: "https://pub.dev" + source: hosted + version: "0.2.6+16" flutter: dependency: "direct main" description: flutter @@ -75,6 +155,19 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -147,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" provider: dependency: "direct main" description: @@ -208,6 +309,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -224,6 +333,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.10.3 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2865480..f9bd9f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,9 @@ environment: dependencies: flutter: sdk: flutter - + firebase_auth: ^5.3.0 + firebase_database: ^11.3.5 + firebase_core: ^3.13.0 # State management provider: ^6.1.5+1 @@ -61,9 +63,9 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/images/ + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..d141b74 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseAuthPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..29944d5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_auth + firebase_core ) list(APPEND FLUTTER_FFI_PLUGIN_LIST