diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b192a61..dd58949 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,16 @@ + + + + + + + + _auth.currentUser; + + bool get isAuthenticated => currentUser != null; + + String? get userId => currentUser?.uid; + + Stream get authStateChanges => _auth.authStateChanges(); + + Future signUp({ + required String email, + required String password, + }) async { + return await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + } + + Future signIn({ + required String email, + required String password, + }) async { + return await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + } + + Future sendPasswordResetEmail({required String email}) async { + await _auth.sendPasswordResetEmail(email: email); + } + + Future confirmPasswordReset({ + required String code, + required String newPassword, + }) async { + await _auth.confirmPasswordReset(code: code, newPassword: newPassword); + } + + Future updatePassword({required String newPassword}) async { + await currentUser?.updatePassword(newPassword); + } + + Future sendEmailVerification() async { + await currentUser?.sendEmailVerification(); + } + + Future signOut() async { + await _auth.signOut(); + } + + Future deleteAccount() async { + await currentUser?.delete(); + } +} diff --git a/lib/core/services/firebase_database_service.dart b/lib/core/services/firebase_database_service.dart index cf701e4..01e6a52 100644 --- a/lib/core/services/firebase_database_service.dart +++ b/lib/core/services/firebase_database_service.dart @@ -1,11 +1,13 @@ import 'package:firebase_database/firebase_database.dart'; import '../../models/onboarding_data_model.dart'; import '../constants.dart'; +import 'firebase_service.dart'; +/// Firebase Realtime Database Service +/// Uses the central FirebaseService instance for all database operations. class FirebaseDatabaseService { - final FirebaseDatabase _database = FirebaseDatabase.instance; + final FirebaseDatabase _database = FirebaseService.instance.database; - DatabaseReference get _usersRef => _database.ref(usersPath); DatabaseReference get _onboardingRef => _database.ref(onboardingPath); Future saveOnboardingData( diff --git a/lib/core/services/firebase_service.dart b/lib/core/services/firebase_service.dart new file mode 100644 index 0000000..41e5445 --- /dev/null +++ b/lib/core/services/firebase_service.dart @@ -0,0 +1,54 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_database/firebase_database.dart'; + +/// Central Firebase service that provides singleton instances +/// of all Firebase services used in the app. +/// +/// This ensures we use pre-initialized instances and avoid +/// creating multiple connections to Firebase. +class FirebaseService { + // Private constructor for singleton pattern + FirebaseService._(); + + // Singleton instance + static final FirebaseService _instance = FirebaseService._(); + static FirebaseService get instance => _instance; + + // Firebase instances (lazy initialized after Firebase.initializeApp()) + FirebaseAuth? _auth; + FirebaseDatabase? _database; + + /// Initialize Firebase and cache the instances + static Future initialize() async { + await Firebase.initializeApp(); + _instance._auth = FirebaseAuth.instance; + _instance._database = FirebaseDatabase.instance; + } + + /// Get the FirebaseAuth instance + FirebaseAuth get auth { + if (_auth == null) { + throw Exception( + 'FirebaseService not initialized. Call FirebaseService.initialize() first.', + ); + } + return _auth!; + } + + /// Get the FirebaseDatabase instance + FirebaseDatabase get database { + if (_database == null) { + throw Exception( + 'FirebaseService not initialized. Call FirebaseService.initialize() first.', + ); + } + return _database!; + } + + // Convenience getters + User? get currentUser => auth.currentUser; + bool get isAuthenticated => currentUser != null; + String? get userId => currentUser?.uid; + Stream get authStateChanges => auth.authStateChanges(); +} diff --git a/lib/core/services/session_service.dart b/lib/core/services/session_service.dart new file mode 100644 index 0000000..cac8b6e --- /dev/null +++ b/lib/core/services/session_service.dart @@ -0,0 +1,178 @@ +import 'package:firebase_database/firebase_database.dart'; +import 'package:vector/models/session_tracking_model.dart'; +import 'package:vector/core/constants.dart'; +import 'package:vector/core/services/firebase_service.dart'; + +/// Service for managing session data in Firebase Realtime Database +class SessionService { + final FirebaseDatabase _database = FirebaseService.instance.database; + + DatabaseReference get _sessionsRef => _database.ref(sessionsPath); + + /// Start a new session + Future startSession({ + required String userId, + required ActivityType activityType, + }) async { + final session = SessionModel( + userId: userId, + activityType: activityType, + startTime: DateTime.now(), + isActive: true, + ); + + final newRef = _sessionsRef.child(userId).push(); + await newRef.set(session.toJson()); + return newRef.key!; + } + + /// Update session with new location data + Future updateSession({ + required String userId, + required String sessionId, + required SessionModel session, + }) async { + await _sessionsRef.child(userId).child(sessionId).update(session.toJson()); + } + + /// End a session + Future endSession({ + required String userId, + required String sessionId, + required SessionModel session, + }) async { + final endedSession = session.copyWith( + endTime: DateTime.now(), + isActive: false, + ); + await _sessionsRef + .child(userId) + .child(sessionId) + .set(endedSession.toJson()); + } + + /// Get all sessions for a user + Future> getUserSessions(String userId) async { + final snapshot = await _sessionsRef.child(userId).get(); + + if (!snapshot.exists || snapshot.value == null) { + return []; + } + + final data = Map.from(snapshot.value as Map); + return data.entries.map((e) { + return SessionModel.fromJson( + Map.from(e.value), + id: e.key, + ); + }).toList(); + } + + /// Get today's sessions for a user + Future> getTodaySessions(String userId) async { + final allSessions = await getUserSessions(userId); + final today = DateTime.now(); + final startOfDay = DateTime(today.year, today.month, today.day); + + return allSessions.where((s) => s.startTime.isAfter(startOfDay)).toList(); + } + + /// Get sessions for a specific date range + Future> getSessionsInRange( + String userId, { + required DateTime startDate, + required DateTime endDate, + }) async { + final allSessions = await getUserSessions(userId); + return allSessions + .where( + (s) => + s.startTime.isAfter(startDate) && + s.startTime.isBefore(endDate.add(const Duration(days: 1))), + ) + .toList() + ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Most recent first + } + + /// Get sessions for the past week + Future> getWeekSessions(String userId) async { + final now = DateTime.now(); + final weekAgo = now.subtract(const Duration(days: 7)); + return getSessionsInRange(userId, startDate: weekAgo, endDate: now); + } + + /// Get sessions for the past month + Future> getMonthSessions(String userId) async { + final now = DateTime.now(); + final monthAgo = DateTime(now.year, now.month - 1, now.day); + return getSessionsInRange(userId, startDate: monthAgo, endDate: now); + } + + /// Get sessions for the past year + Future> getYearSessions(String userId) async { + final now = DateTime.now(); + final yearAgo = DateTime(now.year - 1, now.month, now.day); + return getSessionsInRange(userId, startDate: yearAgo, endDate: now); + } + + /// Get the active session if any + Future getActiveSession(String userId) async { + final sessions = await getUserSessions(userId); + try { + return sessions.firstWhere((s) => s.isActive); + } catch (_) { + return null; + } + } + + /// Delete a session + Future deleteSession({ + required String userId, + required String sessionId, + }) async { + await _sessionsRef.child(userId).child(sessionId).remove(); + } + + /// Calculate calories burned based on activity type and distance + static double calculateCalories({ + required ActivityType activityType, + required double distanceMeters, + required double weightKg, + }) { + // MET values (Metabolic Equivalent of Task) + // Walking: 3.5 MET, Running: 9.8 MET, Cycling: 7.5 MET + double met; + switch (activityType) { + case ActivityType.walking: + met = 3.5; + break; + case ActivityType.running: + met = 9.8; + break; + case ActivityType.cycling: + met = 7.5; + break; + } + + // Estimate duration based on average speeds + // Walking: 5 km/h, Running: 10 km/h, Cycling: 20 km/h + double avgSpeedKmh; + switch (activityType) { + case ActivityType.walking: + avgSpeedKmh = 5.0; + break; + case ActivityType.running: + avgSpeedKmh = 10.0; + break; + case ActivityType.cycling: + avgSpeedKmh = 20.0; + break; + } + + final distanceKm = distanceMeters / 1000; + final durationHours = distanceKm / avgSpeedKmh; + + // Calories = MET × weight (kg) × time (hours) + return met * weightKg * durationHours; + } +} diff --git a/lib/main.dart b/lib/main.dart index dc96408..4571591 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:vector/view_models/home_view_model.dart'; -import 'package:vector/view_models/onboarding_view_model.dart'; -import 'package:vector/core/utils/firebase_test_util.dart'; -import 'package:vector/views/welcome.dart'; +import 'view_models/home_view_model.dart'; +import 'view_models/onboarding_view_model.dart'; +import 'view_models/auth_view_model.dart'; +import 'view_models/session_view_model.dart'; +import 'view_models/profile_view_model.dart'; +import 'core/services/firebase_service.dart'; +import 'core/utils/firebase_test_util.dart'; +import 'widgets/auth_wrapper.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(); + await FirebaseService.initialize(); // Run Firebase tests in debug mode assert(() { @@ -27,8 +30,11 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider(create: (_) => AuthViewModel()), ChangeNotifierProvider(create: (_) => HomeViewModel()), ChangeNotifierProvider(create: (_) => OnboardingViewModel()), + ChangeNotifierProvider(create: (_) => SessionViewModel()), + ChangeNotifierProvider(create: (_) => ProfileViewModel()), ], child: MaterialApp( title: 'Vector', @@ -37,7 +43,7 @@ class MyApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), - home: const WelcomePage(), + home: const AuthWrapper(), ), ); } diff --git a/lib/models/session_tracking_model.dart b/lib/models/session_tracking_model.dart new file mode 100644 index 0000000..9d1a14c --- /dev/null +++ b/lib/models/session_tracking_model.dart @@ -0,0 +1,251 @@ +import 'package:latlong2/latlong.dart'; + +/// Activity types supported in the app +enum ActivityType { walking, running, cycling } + +/// Extension to get display properties for activity types +extension ActivityTypeExtension on ActivityType { + String get displayName { + switch (this) { + case ActivityType.walking: + return 'Walking'; + case ActivityType.running: + return 'Running'; + case ActivityType.cycling: + return 'Cycling'; + } + } + + String get icon { + switch (this) { + case ActivityType.walking: + return '🚶'; + case ActivityType.running: + return '🏃'; + case ActivityType.cycling: + return '🚴'; + } + } +} + +/// Represents a single location point in a session +class LocationPoint { + final double latitude; + final double longitude; + final double altitude; + final double speed; // in m/s + final DateTime timestamp; + + LocationPoint({ + required this.latitude, + required this.longitude, + this.altitude = 0.0, + this.speed = 0.0, + required this.timestamp, + }); + + LatLng get latLng => LatLng(latitude, longitude); + + Map toJson() => { + 'latitude': latitude, + 'longitude': longitude, + 'altitude': altitude, + 'speed': speed, + 'timestamp': timestamp.toIso8601String(), + }; + + factory LocationPoint.fromJson(Map json) => LocationPoint( + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + altitude: (json['altitude'] as num?)?.toDouble() ?? 0.0, + speed: (json['speed'] as num?)?.toDouble() ?? 0.0, + timestamp: DateTime.parse(json['timestamp'] as String), + ); +} + +/// Represents a fitness tracking session +class SessionModel { + final String? id; + final String userId; + final ActivityType activityType; + final DateTime startTime; + final DateTime? endTime; + final List route; + final double totalDistance; // in meters + final Duration duration; + final double averageSpeed; // in m/s + final double maxSpeed; // in m/s + final double caloriesBurned; + final bool isActive; + + SessionModel({ + this.id, + required this.userId, + required this.activityType, + required this.startTime, + this.endTime, + this.route = const [], + this.totalDistance = 0.0, + this.duration = Duration.zero, + this.averageSpeed = 0.0, + this.maxSpeed = 0.0, + this.caloriesBurned = 0.0, + this.isActive = true, + }); + + /// Create a copy with updated fields + SessionModel copyWith({ + String? id, + String? userId, + ActivityType? activityType, + DateTime? startTime, + DateTime? endTime, + List? route, + double? totalDistance, + Duration? duration, + double? averageSpeed, + double? maxSpeed, + double? caloriesBurned, + bool? isActive, + }) { + return SessionModel( + id: id ?? this.id, + userId: userId ?? this.userId, + activityType: activityType ?? this.activityType, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + route: route ?? this.route, + totalDistance: totalDistance ?? this.totalDistance, + duration: duration ?? this.duration, + averageSpeed: averageSpeed ?? this.averageSpeed, + maxSpeed: maxSpeed ?? this.maxSpeed, + caloriesBurned: caloriesBurned ?? this.caloriesBurned, + isActive: isActive ?? this.isActive, + ); + } + + /// Convert distance to km + double get distanceInKm => totalDistance / 1000; + + /// Convert speed to km/h + double get averageSpeedKmh => averageSpeed * 3.6; + double get maxSpeedKmh => maxSpeed * 3.6; + + /// Format duration as HH:MM:SS + String get formattedDuration { + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } + + /// Convert to JSON for Firebase + Map toJson() => { + 'userId': userId, + 'activityType': activityType.name, + 'startTime': startTime.toIso8601String(), + 'endTime': endTime?.toIso8601String(), + 'route': route.map((p) => p.toJson()).toList(), + 'totalDistance': totalDistance, + 'durationSeconds': duration.inSeconds, + 'averageSpeed': averageSpeed, + 'maxSpeed': maxSpeed, + 'caloriesBurned': caloriesBurned, + 'isActive': isActive, + }; + + /// Create from Firebase JSON + factory SessionModel.fromJson(Map json, {String? id}) { + final routeList = + (json['route'] as List?) + ?.map((e) => LocationPoint.fromJson(Map.from(e))) + .toList() ?? + []; + + return SessionModel( + id: id, + userId: json['userId'] as String, + activityType: ActivityType.values.firstWhere( + (e) => e.name == json['activityType'], + orElse: () => ActivityType.walking, + ), + startTime: DateTime.parse(json['startTime'] as String), + endTime: json['endTime'] != null + ? DateTime.parse(json['endTime'] as String) + : null, + route: routeList, + totalDistance: (json['totalDistance'] as num?)?.toDouble() ?? 0.0, + duration: Duration( + seconds: (json['durationSeconds'] as num?)?.toInt() ?? 0, + ), + averageSpeed: (json['averageSpeed'] as num?)?.toDouble() ?? 0.0, + maxSpeed: (json['maxSpeed'] as num?)?.toDouble() ?? 0.0, + caloriesBurned: (json['caloriesBurned'] as num?)?.toDouble() ?? 0.0, + isActive: json['isActive'] as bool? ?? false, + ); + } +} + +/// Summary of today's activities +class TodaySummary { + final double walkingDistance; + final Duration walkingDuration; + final double cyclingDistance; + final Duration cyclingDuration; + final double runningDistance; + final Duration runningDuration; + final double totalCalories; + + TodaySummary({ + this.walkingDistance = 0.0, + this.walkingDuration = Duration.zero, + this.cyclingDistance = 0.0, + this.cyclingDuration = Duration.zero, + this.runningDistance = 0.0, + this.runningDuration = Duration.zero, + this.totalCalories = 0.0, + }); + + factory TodaySummary.fromSessions(List sessions) { + double walkDist = 0, cycleDist = 0, runDist = 0; + Duration walkDur = Duration.zero, + cycleDur = Duration.zero, + runDur = Duration.zero; + double calories = 0; + + for (final session in sessions) { + switch (session.activityType) { + case ActivityType.walking: + walkDist += session.totalDistance; + walkDur += session.duration; + break; + case ActivityType.cycling: + cycleDist += session.totalDistance; + cycleDur += session.duration; + break; + case ActivityType.running: + runDist += session.totalDistance; + runDur += session.duration; + break; + } + calories += session.caloriesBurned; + } + + return TodaySummary( + walkingDistance: walkDist, + walkingDuration: walkDur, + cyclingDistance: cycleDist, + cyclingDuration: cycleDur, + runningDistance: runDist, + runningDuration: runDur, + totalCalories: calories, + ); + } + + String formatDuration(Duration d) { + final hours = d.inHours.toString().padLeft(2, '0'); + final minutes = (d.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } +} diff --git a/lib/models/user_model.dart b/lib/models/user_model.dart index e69de29..50128a4 100644 --- a/lib/models/user_model.dart +++ b/lib/models/user_model.dart @@ -0,0 +1,66 @@ +/// User profile model +class UserProfile { + final String? userId; + final String? name; + final String? email; + final String? location; + final String? photoUrl; + final DateTime? createdAt; + final DateTime? updatedAt; + + UserProfile({ + this.userId, + this.name, + this.email, + this.location, + this.photoUrl, + this.createdAt, + this.updatedAt, + }); + + Map toJson() => { + 'userId': userId, + 'name': name, + 'email': email, + 'location': location, + 'photoUrl': photoUrl, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + + factory UserProfile.fromJson(Map json) { + return UserProfile( + userId: json['userId'] as String?, + name: json['name'] as String?, + email: json['email'] as String?, + location: json['location'] as String?, + photoUrl: json['photoUrl'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + UserProfile copyWith({ + String? userId, + String? name, + String? email, + String? location, + String? photoUrl, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return UserProfile( + userId: userId ?? this.userId, + name: name ?? this.name, + email: email ?? this.email, + location: location ?? this.location, + photoUrl: photoUrl ?? this.photoUrl, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/view_models/auth_view_model.dart b/lib/view_models/auth_view_model.dart new file mode 100644 index 0000000..cae8edb --- /dev/null +++ b/lib/view_models/auth_view_model.dart @@ -0,0 +1,215 @@ +import 'package:flutter/foundation.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../core/services/firebase_auth_service.dart'; + +enum AuthState { initial, loading, authenticated, unauthenticated, error } + +enum AuthError { + invalidEmail, + userNotFound, + wrongPassword, + emailInUse, + weakPassword, + networkError, + unknown, +} + +class AuthViewModel extends ChangeNotifier { + final FirebaseAuthService _authService = FirebaseAuthService(); + + AuthState _state = AuthState.initial; + AuthError? _errorType; + String? _resetEmail; + + AuthState get state => _state; + AuthError? get errorType => _errorType; + bool get isLoading => _state == AuthState.loading; + bool get isAuthenticated => _authService.isAuthenticated; + User? get currentUser => _authService.currentUser; + String? get userId => _authService.userId; + String? get resetEmail => _resetEmail; + + String? get error { + if (_errorType == null) return null; + switch (_errorType!) { + case AuthError.invalidEmail: + return 'Invalid email address.'; + case AuthError.userNotFound: + return 'No account found with this email.'; + case AuthError.wrongPassword: + return 'Incorrect password.'; + case AuthError.emailInUse: + return 'Email is already registered.'; + case AuthError.weakPassword: + return 'Password is too weak.'; + case AuthError.networkError: + return 'Network error. Check your connection.'; + case AuthError.unknown: + return 'An unexpected error occurred.'; + } + } + + void _setLoading() { + _state = AuthState.loading; + _errorType = null; + notifyListeners(); + } + + void _setAuthenticated() { + _state = AuthState.authenticated; + _errorType = null; + notifyListeners(); + } + + void _setUnauthenticated() { + _state = AuthState.unauthenticated; + notifyListeners(); + } + + void _setError(AuthError error) { + _state = AuthState.error; + _errorType = error; + notifyListeners(); + } + + AuthError _mapFirebaseError(FirebaseAuthException e) { + switch (e.code) { + case 'invalid-email': + return AuthError.invalidEmail; + case 'user-not-found': + return AuthError.userNotFound; + case 'wrong-password': + return AuthError.wrongPassword; + case 'email-already-in-use': + return AuthError.emailInUse; + case 'weak-password': + return AuthError.weakPassword; + case 'network-request-failed': + return AuthError.networkError; + default: + return AuthError.unknown; + } + } + + Future register({ + required String email, + required String password, + }) async { + if (email.isEmpty || password.isEmpty) { + _setError(AuthError.invalidEmail); + return false; + } + + _setLoading(); + + try { + await _authService.signUp(email: email, password: password); + _setAuthenticated(); + return true; + } on FirebaseAuthException catch (e) { + _setError(_mapFirebaseError(e)); + return false; + } catch (e) { + _setError(AuthError.unknown); + return false; + } + } + + Future login({required String email, required String password}) async { + if (email.isEmpty || password.isEmpty) { + _setError(AuthError.invalidEmail); + return false; + } + + _setLoading(); + + try { + await _authService.signIn(email: email, password: password); + _setAuthenticated(); + return true; + } on FirebaseAuthException catch (e) { + _setError(_mapFirebaseError(e)); + return false; + } catch (e) { + _setError(AuthError.unknown); + return false; + } + } + + Future resetPassword({required String email}) async { + if (email.isEmpty) { + _setError(AuthError.invalidEmail); + return false; + } + + _setLoading(); + _resetEmail = email; + + try { + await _authService.sendPasswordResetEmail(email: email); + _state = AuthState.unauthenticated; + _errorType = null; + notifyListeners(); + return true; + } on FirebaseAuthException catch (e) { + _setError(_mapFirebaseError(e)); + return false; + } catch (e) { + _setError(AuthError.unknown); + return false; + } + } + + Future confirmNewPassword({ + required String code, + required String newPassword, + }) async { + if (code.isEmpty || newPassword.isEmpty) { + _setError(AuthError.weakPassword); + return false; + } + + _setLoading(); + + try { + await _authService.confirmPasswordReset( + code: code, + newPassword: newPassword, + ); + _setUnauthenticated(); + return true; + } on FirebaseAuthException catch (e) { + _setError(_mapFirebaseError(e)); + return false; + } catch (e) { + _setError(AuthError.unknown); + return false; + } + } + + Future signOut() async { + _setLoading(); + try { + await _authService.signOut(); + _setUnauthenticated(); + } catch (e) { + _setError(AuthError.unknown); + } + } + + void clearError() { + _errorType = null; + if (_state == AuthState.error) { + _state = AuthState.unauthenticated; + } + notifyListeners(); + } + + void checkAuthState() { + if (_authService.isAuthenticated) { + _setAuthenticated(); + } else { + _setUnauthenticated(); + } + } +} diff --git a/lib/view_models/profile_view_model.dart b/lib/view_models/profile_view_model.dart new file mode 100644 index 0000000..33c4b0c --- /dev/null +++ b/lib/view_models/profile_view_model.dart @@ -0,0 +1,272 @@ +import 'package:flutter/foundation.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_database/firebase_database.dart'; +import 'package:geolocator/geolocator.dart' hide ActivityType; +import 'package:geocoding/geocoding.dart'; +import 'package:vector/models/user_model.dart'; +import 'package:vector/models/session_tracking_model.dart'; +import 'package:vector/core/services/session_service.dart'; +import 'package:vector/core/services/firebase_service.dart'; +import 'package:vector/core/constants.dart'; + +enum HistoryFilter { week, month, year } + +class ProfileViewModel extends ChangeNotifier { + final SessionService _sessionService = SessionService(); + final FirebaseDatabase _database = FirebaseService.instance.database; + + UserProfile? _profile; + List _sessionHistory = []; + HistoryFilter _currentFilter = HistoryFilter.week; + bool _isLoading = false; + String? _error; + + // Getters + UserProfile? get profile => _profile; + List get sessionHistory => _sessionHistory; + HistoryFilter get currentFilter => _currentFilter; + bool get isLoading => _isLoading; + String? get error => _error; + + String? get userId => FirebaseService.instance.userId; + User? get currentUser => FirebaseService.instance.currentUser; + + /// Get user display name + String get displayName { + if (_profile?.name != null && _profile!.name!.isNotEmpty) { + return _profile!.name!; + } + if (currentUser?.displayName != null && + currentUser!.displayName!.isNotEmpty) { + return currentUser!.displayName!; + } + if (currentUser?.email != null) { + return currentUser!.email!.split('@').first; + } + return 'User'; + } + + /// Get user location + String get location => _profile?.location ?? 'Location not set'; + + /// Get user email + String get email => currentUser?.email ?? ''; + + /// Get user photo URL + String? get photoUrl => currentUser?.photoURL ?? _profile?.photoUrl; + + /// Load user profile from Firebase + Future loadProfile() async { + if (userId == null) return; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final snapshot = await _database.ref('$usersPath/$userId/profile').get(); + + if (snapshot.exists && snapshot.value != null) { + final data = Map.from(snapshot.value as Map); + _profile = UserProfile.fromJson(data); + } else { + // Create default profile from auth data + _profile = UserProfile( + userId: userId, + name: currentUser?.displayName, + email: currentUser?.email, + photoUrl: currentUser?.photoURL, + ); + } + } catch (e) { + _error = 'Failed to load profile'; + debugPrint('Error loading profile: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Save/update user profile + Future updateProfile({ + String? name, + String? location, + String? photoUrl, + }) async { + if (userId == null) return false; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final updatedProfile = (_profile ?? UserProfile()).copyWith( + userId: userId, + name: name ?? _profile?.name, + email: currentUser?.email, + location: location ?? _profile?.location, + photoUrl: photoUrl ?? _profile?.photoUrl, + updatedAt: DateTime.now(), + createdAt: _profile?.createdAt ?? DateTime.now(), + ); + + await _database + .ref('$usersPath/$userId/profile') + .set(updatedProfile.toJson()); + _profile = updatedProfile; + + // Also update Firebase Auth display name if name changed + if (name != null && name.isNotEmpty) { + await currentUser?.updateDisplayName(name); + } + + _isLoading = false; + notifyListeners(); + return true; + } catch (e) { + _error = 'Failed to update profile'; + debugPrint('Error updating profile: $e'); + _isLoading = false; + notifyListeners(); + return false; + } + } + + /// Fetch current location and update profile + Future updateLocationFromGPS() async { + try { + // Check location permission + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + _error = 'Location permission denied'; + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + _error = 'Location permission permanently denied'; + return false; + } + + // Get current position + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + // Reverse geocode to get city/country + final placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + + if (placemarks.isNotEmpty) { + final placemark = placemarks.first; + final city = + placemark.locality ?? placemark.subAdministrativeArea ?? ''; + final country = placemark.country ?? ''; + final locationString = city.isNotEmpty && country.isNotEmpty + ? '$city, $country' + : city.isNotEmpty + ? city + : country; + + if (locationString.isNotEmpty) { + return await updateProfile(location: locationString); + } + } + + return false; + } catch (e) { + _error = 'Failed to get location'; + debugPrint('Error getting location: $e'); + return false; + } + } + + /// Set history filter and reload sessions + Future setFilter(HistoryFilter filter) async { + _currentFilter = filter; + notifyListeners(); + await loadSessionHistory(); + } + + /// Load session history based on current filter + Future loadSessionHistory() async { + if (userId == null) return; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + switch (_currentFilter) { + case HistoryFilter.week: + _sessionHistory = await _sessionService.getWeekSessions(userId!); + break; + case HistoryFilter.month: + _sessionHistory = await _sessionService.getMonthSessions(userId!); + break; + case HistoryFilter.year: + _sessionHistory = await _sessionService.getYearSessions(userId!); + break; + } + } catch (e) { + _error = 'Failed to load session history'; + debugPrint('Error loading session history: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Group sessions by date for display + Map> get groupedSessions { + final Map> grouped = {}; + + for (final session in _sessionHistory) { + final dateKey = _formatDateKey(session.startTime); + grouped.putIfAbsent(dateKey, () => []); + grouped[dateKey]!.add(session); + } + + return grouped; + } + + String _formatDateKey(DateTime date) { + final months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${date.day} ${months[date.month - 1]},${date.year}'; + } + + /// Initialize profile and load session history + Future initialize() async { + await loadProfile(); + await loadSessionHistory(); + } + + /// Clear all data (for logout) + void clear() { + _profile = null; + _sessionHistory = []; + _currentFilter = HistoryFilter.week; + _isLoading = false; + _error = null; + notifyListeners(); + } +} diff --git a/lib/view_models/session_view_model.dart b/lib/view_models/session_view_model.dart new file mode 100644 index 0000000..653e2c2 --- /dev/null +++ b/lib/view_models/session_view_model.dart @@ -0,0 +1,320 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart' hide ActivityType; +import 'package:vector/models/session_tracking_model.dart'; +import 'package:vector/core/services/session_service.dart'; +import 'package:vector/core/services/firebase_service.dart'; + +/// ViewModel for managing session tracking state +class SessionViewModel extends ChangeNotifier { + final SessionService _sessionService = SessionService(); + + // State + SessionModel? _activeSession; + String? _activeSessionId; + List _todaySessions = []; + TodaySummary _todaySummary = TodaySummary(); + bool _isLoading = false; + String? _error; + Position? _currentPosition; + double _currentSpeed = 0.0; + StreamSubscription? _positionSubscription; + Timer? _durationTimer; + + // Getters + SessionModel? get activeSession => _activeSession; + String? get activeSessionId => _activeSessionId; + List get todaySessions => _todaySessions; + TodaySummary get todaySummary => _todaySummary; + bool get isLoading => _isLoading; + String? get error => _error; + bool get hasActiveSession => _activeSession != null; + Position? get currentPosition => _currentPosition; + double get currentSpeed => _currentSpeed; + double get currentSpeedKmh => _currentSpeed * 3.6; + + String? get _userId => FirebaseService.instance.userId; + + /// Load today's sessions and summary + Future loadTodaySessions() async { + if (_userId == null) return; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _todaySessions = await _sessionService.getTodaySessions(_userId!); + _todaySummary = TodaySummary.fromSessions(_todaySessions); + + // Check for active session + final active = await _sessionService.getActiveSession(_userId!); + if (active != null) { + _activeSession = active; + _activeSessionId = active.id; + } + } catch (e) { + _error = 'Failed to load sessions: $e'; + } + + _isLoading = false; + notifyListeners(); + } + + /// Check and request location permissions + Future checkLocationPermission() async { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _error = 'Location services are disabled.'; + notifyListeners(); + return false; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + _error = 'Location permissions are denied.'; + notifyListeners(); + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + _error = 'Location permissions are permanently denied.'; + notifyListeners(); + return false; + } + + return true; + } + + /// Start a new tracking session + Future startSession(ActivityType activityType) async { + if (_userId == null) { + _error = 'User not authenticated'; + notifyListeners(); + return false; + } + + // Check permissions + final hasPermission = await checkLocationPermission(); + if (!hasPermission) return false; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + // Get initial position + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + _currentPosition = position; + + // Create session in Firebase + _activeSessionId = await _sessionService.startSession( + userId: _userId!, + activityType: activityType, + ); + + // Initialize active session + _activeSession = SessionModel( + id: _activeSessionId, + userId: _userId!, + activityType: activityType, + startTime: DateTime.now(), + route: [ + LocationPoint( + latitude: position.latitude, + longitude: position.longitude, + altitude: position.altitude, + speed: position.speed, + timestamp: DateTime.now(), + ), + ], + isActive: true, + ); + + // Start location tracking + _startLocationTracking(); + + // Start duration timer + _startDurationTimer(); + + _isLoading = false; + notifyListeners(); + return true; + } catch (e) { + _error = 'Failed to start session: $e'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + /// Start listening to location updates + void _startLocationTracking() { + const locationSettings = LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, // Update every 5 meters + ); + + _positionSubscription = + Geolocator.getPositionStream(locationSettings: locationSettings).listen( + (Position position) { + _onLocationUpdate(position); + }, + ); + } + + /// Handle location update + void _onLocationUpdate(Position position) { + if (_activeSession == null) return; + + _currentPosition = position; + _currentSpeed = position.speed.clamp(0, double.infinity); + + // Create new location point + final newPoint = LocationPoint( + latitude: position.latitude, + longitude: position.longitude, + altitude: position.altitude, + speed: position.speed, + timestamp: DateTime.now(), + ); + + // Calculate distance from last point + double addedDistance = 0; + if (_activeSession!.route.isNotEmpty) { + final lastPoint = _activeSession!.route.last; + addedDistance = _calculateDistance( + lastPoint.latitude, + lastPoint.longitude, + newPoint.latitude, + newPoint.longitude, + ); + } + + // Update session + final newRoute = [..._activeSession!.route, newPoint]; + final newTotalDistance = _activeSession!.totalDistance + addedDistance; + final newMaxSpeed = max(_activeSession!.maxSpeed, position.speed); + + // Calculate average speed + final duration = DateTime.now().difference(_activeSession!.startTime); + final avgSpeed = duration.inSeconds > 0 + ? newTotalDistance / duration.inSeconds + : 0.0; + + _activeSession = _activeSession!.copyWith( + route: newRoute, + totalDistance: newTotalDistance, + maxSpeed: newMaxSpeed, + averageSpeed: avgSpeed, + duration: duration, + ); + + notifyListeners(); + } + + /// Start timer to update duration + void _startDurationTimer() { + _durationTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_activeSession == null) return; + + final duration = DateTime.now().difference(_activeSession!.startTime); + _activeSession = _activeSession!.copyWith(duration: duration); + notifyListeners(); + }); + } + + /// Stop the current session + Future stopSession({double userWeightKg = 70}) async { + if (_activeSession == null || _activeSessionId == null || _userId == null) { + return; + } + + _isLoading = true; + notifyListeners(); + + try { + // Stop tracking + await _positionSubscription?.cancel(); + _positionSubscription = null; + _durationTimer?.cancel(); + _durationTimer = null; + + // Calculate calories + final calories = SessionService.calculateCalories( + activityType: _activeSession!.activityType, + distanceMeters: _activeSession!.totalDistance, + weightKg: userWeightKg, + ); + + // Update session with final data + final finalSession = _activeSession!.copyWith( + caloriesBurned: calories, + isActive: false, + endTime: DateTime.now(), + ); + + // Save to Firebase + await _sessionService.endSession( + userId: _userId!, + sessionId: _activeSessionId!, + session: finalSession, + ); + + // Add to today's sessions + _todaySessions.add(finalSession); + _todaySummary = TodaySummary.fromSessions(_todaySessions); + + // Clear active session + _activeSession = null; + _activeSessionId = null; + _currentSpeed = 0; + } catch (e) { + _error = 'Failed to save session: $e'; + } + + _isLoading = false; + notifyListeners(); + } + + /// Calculate distance between two points using Haversine formula + double _calculateDistance( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const double earthRadius = 6371000; // meters + final dLat = _degreesToRadians(lat2 - lat1); + final dLon = _degreesToRadians(lon2 - lon1); + + final a = + sin(dLat / 2) * sin(dLat / 2) + + cos(_degreesToRadians(lat1)) * + cos(_degreesToRadians(lat2)) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return earthRadius * c; + } + + double _degreesToRadians(double degrees) => degrees * pi / 180; + + /// Clean up resources + @override + void dispose() { + _positionSubscription?.cancel(); + _durationTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/views/forgetpass_view.dart b/lib/views/forgetpass_view.dart new file mode 100644 index 0000000..9cbef7d --- /dev/null +++ b/lib/views/forgetpass_view.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; +import 'login_view.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( + ' { 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!', - ), + final authVM = context.read(); + final userId = authVM.userId; + + if (userId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please login first')), + ); + return; + } + + final success = await vm.save(userId); + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Onboarding completed successfully!', ), - ); - // TODO: Navigate to home screen - } + ), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const HomeView()), + (route) => false, + ); } } }, diff --git a/lib/views/home_view.dart b/lib/views/home_view.dart new file mode 100644 index 0000000..6264411 --- /dev/null +++ b/lib/views/home_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; +import '../core/constants.dart'; +import '../widgets/auth_wrapper.dart'; +import 'sessions_page.dart'; + +class HomeView extends StatelessWidget { + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + final authVM = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + appBar: AppBar( + backgroundColor: const Color(colorBackground), + foregroundColor: const Color(colorTextSecondary), + title: const Text('Vector'), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await authVM.signOut(); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const AuthWrapper()), + (route) => false, + ); + }, + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.check_circle, + color: Color(colorPrimary), + size: 80, + ), + const SizedBox(height: 20), + const Text( + 'Welcome to Vector!', + style: TextStyle( + color: Colors.white, + fontSize: textSizeXLarge, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + const Text( + 'Your fitness journey starts here.', + style: TextStyle(color: Colors.white70, fontSize: textSizeMedium), + ), + const SizedBox(height: 40), + ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SessionsPage()), + ); + }, + icon: const Icon(Icons.play_arrow), + label: const Text('Start Session'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(colorSessionPink), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/live_tracking_view.dart b/lib/views/live_tracking_view.dart new file mode 100644 index 0000000..8d2ea0f --- /dev/null +++ b/lib/views/live_tracking_view.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; +import 'package:vector/view_models/session_view_model.dart'; +import 'package:vector/models/session_tracking_model.dart'; +import 'package:vector/core/constants.dart'; + +class LiveTrackingView extends StatefulWidget { + const LiveTrackingView({super.key}); + + @override + State createState() => _LiveTrackingViewState(); +} + +class _LiveTrackingViewState extends State { + final MapController _mapController = MapController(); + + @override + Widget build(BuildContext context) { + final sessionVM = context.watch(); + final activeSession = sessionVM.activeSession; + + if (activeSession == null) { + return const Scaffold( + backgroundColor: Color(colorBackground), + body: Center( + child: Text( + 'No active session', + style: TextStyle(color: Colors.white), + ), + ), + ); + } + + // Build polyline from route points + final polylinePoints = activeSession.route + .map((p) => LatLng(p.latitude, p.longitude)) + .toList(); + + // Get current position for camera + final currentPos = sessionVM.currentPosition; + final center = currentPos != null + ? LatLng(currentPos.latitude, currentPos.longitude) + : const LatLng(0, 0); + + return Scaffold( + body: Stack( + children: [ + // Map + FlutterMap( + mapController: _mapController, + options: MapOptions(initialCenter: center, initialZoom: 16), + children: [ + // OpenStreetMap tile layer + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.example.vector', + ), + + // Route polyline + if (polylinePoints.length >= 2) + PolylineLayer( + polylines: [ + Polyline( + points: polylinePoints, + color: const Color(0xFF2196F3), + strokeWidth: 6, + ), + ], + ), + + // Markers + MarkerLayer( + markers: [ + // Start marker + if (polylinePoints.isNotEmpty) + Marker( + point: polylinePoints.first, + width: 40, + height: 40, + child: const Icon( + Icons.location_on, + color: Colors.red, + size: 40, + ), + ), + // Current position marker + if (currentPos != null) + Marker( + point: LatLng(currentPos.latitude, currentPos.longitude), + width: 24, + height: 24, + child: Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + ), + ), + ), + ], + ), + ], + ), + + // Bottom control panel + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildControlPanel(sessionVM, activeSession), + ), + + // Center on user button + Positioned( + right: 16, + bottom: 280, + child: FloatingActionButton.small( + onPressed: () { + if (currentPos != null) { + _mapController.move( + LatLng(currentPos.latitude, currentPos.longitude), + 16, + ); + } + }, + backgroundColor: Colors.white, + child: const Icon(Icons.my_location, color: Colors.black87), + ), + ), + ], + ), + ); + } + + Widget _buildControlPanel(SessionViewModel sessionVM, dynamic activeSession) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 10, + offset: Offset(0, -2), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag indicator + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + + // Stats Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatColumn( + value: activeSession.distanceInKm.toStringAsFixed(2), + unit: 'km', + label: 'Distance', + ), + _buildStatColumn( + value: activeSession.formattedDuration, + unit: '', + label: 'Duration', + ), + _buildStatColumn( + value: sessionVM.currentSpeedKmh.toStringAsFixed(1), + unit: 'km/h', + label: 'Speed', + ), + ], + ), + + const SizedBox(height: 24), + + // Control buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Activity icon + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.grey[300]!, width: 2), + ), + child: Icon( + _getActivityIcon(activeSession.activityType), + color: Colors.grey[700], + size: 28, + ), + ), + + // Stop button + GestureDetector( + onTap: () => _stopSession(sessionVM), + child: Container( + width: 72, + height: 72, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(colorSessionPink), + ), + child: const Icon(Icons.stop, color: Colors.white, size: 36), + ), + ), + + // Speed display button + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.grey[300]!, width: 2), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + sessionVM.currentSpeedKmh.toStringAsFixed(0), + style: TextStyle( + color: Colors.grey[700], + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'km/h', + style: TextStyle(color: Colors.grey[500], fontSize: 8), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + ], + ), + ); + } + + Widget _buildStatColumn({ + required String value, + required String unit, + required String label, + }) { + return Column( + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: const TextStyle( + color: Colors.black87, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (unit.isNotEmpty) + TextSpan( + text: ' $unit', + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), + ], + ), + ), + const SizedBox(height: 4), + Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)), + ], + ); + } + + IconData _getActivityIcon(ActivityType activityType) { + switch (activityType) { + case ActivityType.walking: + return Icons.directions_walk; + case ActivityType.running: + return Icons.directions_run; + case ActivityType.cycling: + return Icons.directions_bike; + } + } + + Future _stopSession(SessionViewModel sessionVM) async { + // Show confirmation dialog + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('End Session?'), + content: const Text('Are you sure you want to end this session?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(colorSessionPink), + ), + child: const Text('End', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm == true) { + // Show loading + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: CircularProgressIndicator(color: Color(colorSessionPink)), + ), + ); + + await sessionVM.stopSession(); + + // Close loading and go back + if (mounted) { + Navigator.pop(context); // Close loading + Navigator.pop(context); // Go back to sessions page + } + } + } +} diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index e69de29..ebce309 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; +import '../views/forgetpass_view.dart'; +import '../widgets/auth_wrapper.dart'; + +class LoginView extends StatefulWidget { + const LoginView({super.key}); + + @override + State 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.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => const AuthWrapper(), + ), + (route) => false, + ); + } 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/views/newpass_view.dart b/lib/views/newpass_view.dart new file mode 100644 index 0000000..5d4e05a --- /dev/null +++ b/lib/views/newpass_view.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; +import 'login_view.dart'; + +class NewPasswordPage extends StatefulWidget { + final String code; + final String email; + + const NewPasswordPage({super.key, required this.code, required this.email}); + + @override + State createState() => _NewPasswordPageState(); +} + +class _NewPasswordPageState extends State { + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + @override + Widget build(BuildContext context) { + final authVM = context.watch(); + + 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(), + ), + (route) => false, + ); + } + }, + child: authVM.isLoading + ? const CircularProgressIndicator(color: Colors.white) + : 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/views/profile_page.dart b/lib/views/profile_page.dart new file mode 100644 index 0000000..28e51f9 --- /dev/null +++ b/lib/views/profile_page.dart @@ -0,0 +1,591 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:vector/view_models/profile_view_model.dart'; +import 'package:vector/models/session_tracking_model.dart'; +import 'package:vector/core/constants.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({super.key}); + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initialize(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(colorBackground), + body: SafeArea( + child: Consumer( + builder: (context, profileVM, child) { + return Column( + children: [ + // Purple header with profile info + _buildProfileHeader(profileVM), + + // Session history section + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Session history', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + + // Filter tabs + _buildFilterTabs(profileVM), + + const SizedBox(height: 20), + + // Session list + Expanded( + child: profileVM.isLoading + ? const Center( + child: CircularProgressIndicator( + color: Color(colorSessionPink), + ), + ) + : profileVM.sessionHistory.isEmpty + ? _buildEmptyState() + : _buildSessionList(profileVM), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildProfileHeader(ProfileViewModel profileVM) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF4A2C6A), Color(colorSessionDarkPurple)], + ), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + border: Border.all(color: Colors.cyan.withValues(alpha: 0.5), width: 2), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + children: [ + // Back button row + Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Row( + children: [ + Icon( + Icons.chevron_left, + color: Colors.white.withValues(alpha: 0.9), + size: 28, + ), + Text( + 'Back', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 20), + + // Avatar + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 2, + ), + ), + child: profileVM.photoUrl != null + ? ClipOval( + child: Image.network( + profileVM.photoUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildDefaultAvatar(), + ), + ) + : _buildDefaultAvatar(), + ), + + const SizedBox(height: 16), + + // Name + Text( + profileVM.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 8), + + // Location + GestureDetector( + onTap: () => _updateLocationFromGPS(profileVM), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.location_on, + color: Colors.redAccent, + size: 18, + ), + const SizedBox(width: 4), + Text( + profileVM.location, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 14, + ), + ), + if (profileVM.location == 'Location not set') ...[ + const SizedBox(width: 8), + Icon( + Icons.my_location, + color: Colors.white.withValues(alpha: 0.6), + size: 16, + ), + ], + ], + ), + ), + + const SizedBox(height: 20), + + // Edit Profile button + ElevatedButton( + onPressed: () => _showEditProfileDialog(profileVM), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.15), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + child: const Text( + 'Edit Profile', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDefaultAvatar() { + return const Icon(Icons.person_outline, size: 50, color: Colors.white54); + } + + Widget _buildFilterTabs(ProfileViewModel profileVM) { + return Container( + height: 44, + decoration: BoxDecoration( + color: const Color(colorSessionDarkPurple), + borderRadius: BorderRadius.circular(22), + ), + child: Row( + children: [ + _buildFilterTab('Week', HistoryFilter.week, profileVM), + _buildFilterTab('Month', HistoryFilter.month, profileVM), + _buildFilterTab('Year', HistoryFilter.year, profileVM), + ], + ), + ); + } + + Widget _buildFilterTab( + String label, + HistoryFilter filter, + ProfileViewModel profileVM, + ) { + final isSelected = profileVM.currentFilter == filter; + + return Expanded( + child: GestureDetector( + onTap: () => profileVM.setFilter(filter), + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(18), + ), + child: Center( + child: Text( + label, + style: TextStyle( + color: isSelected + ? const Color(colorSessionDarkPurple) + : Colors.white, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.directions_run, + size: 64, + color: Colors.grey.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'No sessions yet', + style: TextStyle( + color: Colors.grey.withValues(alpha: 0.7), + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + 'Start a workout to see your history', + style: TextStyle( + color: Colors.grey.withValues(alpha: 0.5), + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildSessionList(ProfileViewModel profileVM) { + final grouped = profileVM.groupedSessions; + final dateKeys = grouped.keys.toList(); + + return ListView.builder( + itemCount: dateKeys.length, + itemBuilder: (context, index) { + final dateKey = dateKeys[index]; + final sessions = grouped[dateKey]!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date header + Padding( + padding: const EdgeInsets.only(bottom: 12, top: 8), + child: Text( + dateKey, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Session cards for this date + ...sessions.map((session) => _buildSessionCard(session)), + + const SizedBox(height: 8), + ], + ); + }, + ); + } + + Widget _buildSessionCard(SessionModel session) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(colorSessionCard), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + // Activity icon + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(colorSessionPink), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _getActivityIcon(session.activityType), + color: Colors.white, + size: 24, + ), + ), + + const SizedBox(width: 16), + + // Activity details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.activityType.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '${(session.totalDistance / 1000).toStringAsFixed(1)} Km', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 13, + ), + ), + Text( + 'distance covered', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 11, + ), + ), + ], + ), + ), + + // Time + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _formatDuration(session.duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + 'Time taken', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 11, + ), + ), + ], + ), + + const SizedBox(width: 16), + + // Calories + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + session.caloriesBurned.toStringAsFixed(0), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + 'Calories burned', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 11, + ), + ), + ], + ), + ], + ), + ); + } + + IconData _getActivityIcon(ActivityType activityType) { + switch (activityType) { + case ActivityType.walking: + return Icons.directions_walk; + case ActivityType.running: + return Icons.directions_run; + case ActivityType.cycling: + return Icons.directions_bike; + } + } + + String _formatDuration(Duration duration) { + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } + + void _showEditProfileDialog(ProfileViewModel profileVM) { + final nameController = TextEditingController( + text: profileVM.profile?.name ?? profileVM.displayName, + ); + final locationController = TextEditingController( + text: profileVM.profile?.location ?? '', + ); + + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1A1A2E), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Edit Profile', + style: TextStyle(color: Colors.white), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Name', + labelStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.3), + ), + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Color(colorSessionPink)), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 16), + TextField( + controller: locationController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Location', + labelStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + ), + hintText: 'e.g., Delhi, India', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.3), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.3), + ), + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Color(colorSessionPink)), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Cancel', + style: TextStyle(color: Colors.white.withValues(alpha: 0.7)), + ), + ), + ElevatedButton( + onPressed: () async { + final success = await profileVM.updateProfile( + name: nameController.text.trim(), + location: locationController.text.trim(), + ); + if (success && context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile updated!'), + backgroundColor: Color(colorSessionPink), + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(colorSessionPink), + ), + child: const Text('Save'), + ), + ], + ), + ); + } + + Future _updateLocationFromGPS(ProfileViewModel profileVM) async { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Getting your location...'), + duration: Duration(seconds: 1), + ), + ); + + final success = await profileVM.updateLocationFromGPS(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success ? 'Location updated!' : 'Could not get location', + ), + backgroundColor: success ? const Color(colorSessionPink) : Colors.red, + ), + ); + } + } +} diff --git a/lib/views/register_view.dart b/lib/views/register_view.dart new file mode 100644 index 0000000..57e66ec --- /dev/null +++ b/lib/views/register_view.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../view_models/auth_view_model.dart'; +import 'login_view.dart'; +import '../widgets/auth_wrapper.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 + : () async { + final success = await authVM.register( + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + ); + if (success) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => const AuthWrapper(), + ), + (route) => false, + ); + } else if (authVM.error != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar(content: Text(authVM.error!)), + ); + } + }, + 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.push( + context, + MaterialPageRoute( + builder: (_) => const LoginView(), + ), + ), + 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/sessions_page.dart b/lib/views/sessions_page.dart new file mode 100644 index 0000000..5678f1c --- /dev/null +++ b/lib/views/sessions_page.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:vector/view_models/session_view_model.dart'; +import 'package:vector/models/session_tracking_model.dart'; +import 'package:vector/core/constants.dart'; +import 'package:vector/views/live_tracking_view.dart'; +import 'package:vector/views/profile_page.dart'; + +class SessionsPage extends StatefulWidget { + const SessionsPage({super.key}); + + @override + State createState() => _SessionsPageState(); +} + +class _SessionsPageState extends State { + ActivityType _selectedActivity = ActivityType.running; + + @override + void initState() { + super.initState(); + // Load today's sessions when page loads + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadTodaySessions(); + }); + } + + @override + Widget build(BuildContext context) { + final sessionVM = context.watch(); + + return Scaffold( + backgroundColor: const Color(colorBackground), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + _buildHeader(), + + const SizedBox(height: 20), + + // Activity Selector Circle + _buildActivitySelector(), + + const SizedBox(height: 20), + + // Current Activity Card (Pink) + _buildCurrentActivityCard(sessionVM), + + const SizedBox(height: 24), + + // TODAY Label + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'TODAY', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + + const SizedBox(height: 16), + + // Activity Cards Grid + _buildActivityCardsGrid(sessionVM), + + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Row( + children: [ + Icon(Icons.chevron_left, color: Colors.white, size: 28), + Text( + 'Back', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + ), + const Expanded( + child: Center( + child: Text( + 'Session', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfilePage()), + ); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white54, width: 2), + ), + child: const Icon(Icons.person_outline, color: Colors.white54), + ), + ), + ], + ), + ); + } + + Widget _buildActivitySelector() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + decoration: BoxDecoration( + color: const Color(colorSessionDarkPurple), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildActivityButton( + icon: Icons.directions_run, + label: 'Run', + activityType: ActivityType.running, + ), + _buildPlayButton(), + _buildActivityButton( + icon: Icons.directions_walk, + label: 'Walk', + activityType: ActivityType.walking, + ), + ], + ), + ); + } + + Widget _buildActivityButton({ + required IconData icon, + required String label, + required ActivityType activityType, + }) { + final isSelected = _selectedActivity == activityType; + + return GestureDetector( + onTap: () { + setState(() { + _selectedActivity = activityType; + }); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? Colors.white + : Colors.white.withValues(alpha: 0.2), + ), + child: Icon( + icon, + color: isSelected + ? const Color(colorSessionDarkPurple) + : Colors.white, + size: 28, + ), + ), + if (isSelected) + Positioned( + right: 0, + top: 0, + child: Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.green, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildPlayButton() { + return GestureDetector( + onTap: () => _startSession(), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + child: const Icon( + Icons.play_arrow, + color: Color(colorSessionDarkPurple), + size: 36, + ), + ), + ); + } + + Future _startSession() async { + final sessionVM = context.read(); + + // Show loading + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: CircularProgressIndicator(color: Color(colorSessionPink)), + ), + ); + + final success = await sessionVM.startSession(_selectedActivity); + + // Close loading + if (mounted) Navigator.pop(context); + + if (success && mounted) { + // Navigate to live tracking + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LiveTrackingView()), + ); + } else if (mounted && sessionVM.error != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(sessionVM.error!))); + } + } + + Widget _buildCurrentActivityCard(SessionViewModel sessionVM) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(colorSessionPink), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Text( + _selectedActivity.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + value: _getDistanceForActivity(sessionVM), + unit: 'km', + label: 'Distance', + ), + _buildStatItem( + value: _getDurationForActivity(sessionVM), + unit: '', + label: 'Time', + ), + ], + ), + ], + ), + ); + } + + String _getDistanceForActivity(SessionViewModel sessionVM) { + switch (_selectedActivity) { + case ActivityType.walking: + return (sessionVM.todaySummary.walkingDistance / 1000).toStringAsFixed( + 1, + ); + case ActivityType.running: + return (sessionVM.todaySummary.runningDistance / 1000).toStringAsFixed( + 1, + ); + case ActivityType.cycling: + return (sessionVM.todaySummary.cyclingDistance / 1000).toStringAsFixed( + 1, + ); + } + } + + String _getDurationForActivity(SessionViewModel sessionVM) { + Duration duration; + switch (_selectedActivity) { + case ActivityType.walking: + duration = sessionVM.todaySummary.walkingDuration; + break; + case ActivityType.running: + duration = sessionVM.todaySummary.runningDuration; + break; + case ActivityType.cycling: + duration = sessionVM.todaySummary.cyclingDuration; + break; + } + return sessionVM.todaySummary.formatDuration(duration); + } + + Widget _buildStatItem({ + required String value, + required String unit, + required String label, + }) { + return Column( + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: const TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: unit, + style: const TextStyle(color: Colors.white70, fontSize: 18), + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 14), + ), + ], + ); + } + + Widget _buildActivityCardsGrid(SessionViewModel sessionVM) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _buildActivityCard( + icon: Icons.directions_walk, + title: 'Walking', + distance: + '${(sessionVM.todaySummary.walkingDistance / 1000).toStringAsFixed(1)} Km', + duration: sessionVM.todaySummary.formatDuration( + sessionVM.todaySummary.walkingDuration, + ), + onTap: () { + setState(() => _selectedActivity = ActivityType.walking); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildActivityCard( + icon: Icons.directions_bike, + title: 'Cycling', + distance: + '${(sessionVM.todaySummary.cyclingDistance / 1000).toStringAsFixed(1)} Km', + duration: sessionVM.todaySummary.formatDuration( + sessionVM.todaySummary.cyclingDuration, + ), + onTap: () { + setState(() => _selectedActivity = ActivityType.cycling); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildActivityCard( + icon: Icons.directions_run, + title: 'Running', + distance: + '${(sessionVM.todaySummary.runningDistance / 1000).toStringAsFixed(1)} Km', + duration: sessionVM.todaySummary.formatDuration( + sessionVM.todaySummary.runningDuration, + ), + onTap: () { + setState(() => _selectedActivity = ActivityType.running); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildActivityCard( + icon: Icons.local_fire_department, + title: 'Calories', + distance: + '${sessionVM.todaySummary.totalCalories.toStringAsFixed(0)} Cal', + duration: _getTotalDuration(sessionVM), + isCalories: true, + onTap: () {}, + ), + ), + ], + ), + ], + ), + ); + } + + String _getTotalDuration(SessionViewModel sessionVM) { + final total = + sessionVM.todaySummary.walkingDuration + + sessionVM.todaySummary.runningDuration + + sessionVM.todaySummary.cyclingDuration; + return sessionVM.todaySummary.formatDuration(total); + } + + Widget _buildActivityCard({ + required IconData icon, + required String title, + required String distance, + required String duration, + required VoidCallback onTap, + bool isCalories = false, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(colorSessionCard), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.2), + ), + child: Icon(icon, color: Colors.white, size: 24), + ), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + isCalories ? 'Calories burned' : 'Distance covered', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + const SizedBox(height: 4), + Text( + distance, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + const Text( + 'Time Taken', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + const SizedBox(height: 4), + Text( + duration, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/verification_view.dart b/lib/views/verification_view.dart new file mode 100644 index 0000000..18cc1bc --- /dev/null +++ b/lib/views/verification_view.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pinput/pinput.dart'; +import '../view_models/auth_view_model.dart'; +import 'forgetpass_view.dart'; +import 'newpass_view.dart'; + +class VerificationPage extends StatefulWidget { + final String email; + + const VerificationPage({super.key, required this.email}); + + @override + State createState() => _VerificationPageState(); +} + +class _VerificationPageState extends State { + String _code = ''; + + @override + Widget build(BuildContext context) { + final authVM = context.watch(); + 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( + ' _code = value); + }, + onCompleted: (value) { + setState(() => _code = value); + }, + ), + const SizedBox(height: 40), + GestureDetector( + onTap: authVM.isLoading + ? null + : () async { + await authVM.resetPassword(email: widget.email); + if (authVM.error == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Verification code resent!'), + ), + ); + } + }, + child: const Text( + 'Resend Email?', + style: TextStyle( + color: Colors.white, + fontSize: 14, + decoration: TextDecoration.underline, + decorationColor: Colors.white, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + ), + ), + ), + const SizedBox(height: 90), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF693298), + fixedSize: const Size(190, 63), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + ), + onPressed: authVM.isLoading || _code.length < 6 + ? null + : () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NewPasswordPage( + code: _code, + email: widget.email, + ), + ), + ); + }, + child: authVM.isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'Verify', + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/auth_wrapper.dart b/lib/widgets/auth_wrapper.dart new file mode 100644 index 0000000..1163119 --- /dev/null +++ b/lib/widgets/auth_wrapper.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../core/services/firebase_service.dart'; +import '../core/services/firebase_database_service.dart'; +import '../views/register_view.dart'; +import '../views/gender.dart'; +import '../views/home_view.dart'; + +/// AuthWrapper - Routes users based on authentication and onboarding status. +/// +/// Flow: +/// - Not logged in → RegisterView +/// - Logged in + Onboarding incomplete → GenderPage (start onboarding) +/// - Logged in + Onboarding complete → HomeView +class AuthWrapper extends StatelessWidget { + const AuthWrapper({super.key}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: FirebaseService.instance.authStateChanges, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Scaffold( + backgroundColor: Color(0xFF0B0B0F), + body: Center( + child: CircularProgressIndicator(color: Color(0xFF693298)), + ), + ); + } + + if (snapshot.hasData && snapshot.data != null) { + // Check if user has completed onboarding + return _OnboardingCheckWrapper(userId: snapshot.data!.uid); + } + + return RegisterView(); + }, + ); + } +} + +/// Checks if user has completed onboarding and routes accordingly +class _OnboardingCheckWrapper extends StatelessWidget { + final String userId; + + const _OnboardingCheckWrapper({required this.userId}); + + @override + Widget build(BuildContext context) { + final databaseService = FirebaseDatabaseService(); + + return FutureBuilder( + future: databaseService.hasCompletedOnboarding(userId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Scaffold( + backgroundColor: Color(0xFF0B0B0F), + body: Center( + child: CircularProgressIndicator(color: Color(0xFF693298)), + ), + ); + } + + // If onboarding is complete, go to HomeView + if (snapshot.hasData && snapshot.data == true) { + return const HomeView(); + } + + // Otherwise, start onboarding + return const GenderPage(); + }, + ); + } +} diff --git a/lib/widgets/bottomnav.dart b/lib/widgets/bottomnav.dart new file mode 100644 index 0000000..100db59 --- /dev/null +++ b/lib/widgets/bottomnav.dart @@ -0,0 +1,80 @@ +/// still needs work to be completed , dont check have asked shourya to work on it + +import 'package:flutter/material.dart'; + +class CustomBottomNav extends StatefulWidget { + const CustomBottomNav({super.key}); + + @override + State createState() => _CustomBottomNavState(); +} + +class _CustomBottomNavState extends State { + int selectedIndex = 0; + + final icons = [ + 'assets/icons/Vector.png', + 'assets/icons/primary.png', + 'assets/icons/SVGRepo_iconCarrier.png', + 'assets/icons/Page_1.png', + ]; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Container( + height: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(40), // ✅ Figma radius + boxShadow: [ + BoxShadow( + color: Colors.black, + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(icons.length, (index) { + final isActive = index == selectedIndex; + + return GestureDetector( + onTap: () { + setState(() => selectedIndex = index); + }, + child: SizedBox( + width: 40, + child: Center( + child: Container( + width: isActive ? 40 : 22, + height: isActive ? 36 : 22, + decoration: BoxDecoration( + color: isActive + ? const Color(0xFF7527AC) + : Colors.transparent, + shape: BoxShape.circle, + ), + child: Center( + child: Image.asset( + icons[index], + width: 20, + height: 20, + color: isActive ? Colors.white : Colors.black, + ), + ), + ), + ), + ), + ); + }), + ), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c194d13..c74b17b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,14 @@ import FlutterMacOS import Foundation +import firebase_auth import firebase_core import firebase_database +import geolocator_apple 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")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 492affc..458bbb6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" fake_async: dependency: transitive description: @@ -65,6 +81,30 @@ 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: @@ -113,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.6+16" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -126,6 +174,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" + url: "https://pub.dev" + source: hosted + version: "7.0.2" flutter_test: dependency: "direct dev" description: flutter @@ -136,6 +192,118 @@ packages: description: flutter source: sdk version: "0.0.0" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: d580c801cba9386b4fac5047c4c785a4e19554f46be42f4f5e5b7deacd088a66 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "1b13eca79b11c497c434678fed109c2be020b158cec7512c848c102bc7232603" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -168,6 +336,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" matcher: dependency: transitive description: @@ -192,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: @@ -208,6 +400,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: c41f42ee301505ae2375ec32871c985d3717bf8aee845620465b286e0140aad2 + url: "https://pub.dev" + source: hosted + version: "5.0.2" plugin_platform_interface: dependency: transitive description: @@ -216,6 +464,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: "direct main" description: @@ -277,6 +541,38 @@ 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" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: @@ -301,6 +597,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" sdks: dart: ">=3.10.3 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index bd1c5a7..a5bff53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,15 @@ dependencies: # Firebase firebase_core: ^3.13.0 firebase_database: ^11.3.5 + firebase_auth: ^5.5.4 + pinput: ^5.0.1 + + # Location & Maps + geolocator: ^13.0.2 + flutter_map: ^7.0.2 + latlong2: ^0.9.1 + permission_handler: ^11.3.1 + geocoding: ^3.0.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1a82e7d..228d224 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseAuthPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fa8a39b..2269029 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_auth firebase_core + geolocator_windows + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST