diff --git a/lifetimer/lib/core/errors/error_mapper.dart b/lifetimer/lib/core/errors/error_mapper.dart new file mode 100644 index 0000000..53908c8 --- /dev/null +++ b/lifetimer/lib/core/errors/error_mapper.dart @@ -0,0 +1,47 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'failure.dart'; + +class ErrorMapper { + static Failure mapError(dynamic error) { + if (error is AuthException) { + switch (error.message) { + case 'Invalid login credentials': + return const AuthFailure('Invalid email or password'); + case 'Email not confirmed': + return const AuthFailure('Please confirm your email address'); + case 'User already registered': + return const AuthFailure('An account with this email already exists'); + case 'Password should be at least 6 characters': + return const AuthFailure('Password must be at least 6 characters'); + default: + return AuthFailure(error.message); + } + } + + if (error is PostgrestException) { + switch (error.code) { + case '23505': + return const ValidationFailure('This record already exists'); + case '23503': + return const ValidationFailure('Referenced record does not exist'); + case '42501': + return const PermissionFailure('You do not have permission to perform this action'); + default: + return ServerFailure( + error.message, + statusCode: int.tryParse(error.code ?? ''), + ); + } + } + + if (error is Exception) { + return NetworkFailure(error.toString().replaceAll('Exception: ', '')); + } + + return UnknownFailure(error.toString()); + } + + static String getUserMessage(Failure failure) { + return failure.message; + } +} diff --git a/lifetimer/lib/core/errors/failure.dart b/lifetimer/lib/core/errors/failure.dart new file mode 100644 index 0000000..a2d16a6 --- /dev/null +++ b/lifetimer/lib/core/errors/failure.dart @@ -0,0 +1,37 @@ +abstract class Failure { + final String message; + final int? statusCode; + + const Failure(this.message, {this.statusCode}); + + @override + String toString() => 'Failure: $message'; +} + +class ServerFailure extends Failure { + const ServerFailure(super.message, {super.statusCode}); +} + +class NetworkFailure extends Failure { + const NetworkFailure(super.message); +} + +class AuthFailure extends Failure { + const AuthFailure(super.message); +} + +class ValidationFailure extends Failure { + const ValidationFailure(super.message); +} + +class NotFoundFailure extends Failure { + const NotFoundFailure(super.message); +} + +class PermissionFailure extends Failure { + const PermissionFailure(super.message); +} + +class UnknownFailure extends Failure { + const UnknownFailure(super.message); +} diff --git a/lifetimer/lib/core/utils/date_time_utils.dart b/lifetimer/lib/core/utils/date_time_utils.dart new file mode 100644 index 0000000..a1630d9 --- /dev/null +++ b/lifetimer/lib/core/utils/date_time_utils.dart @@ -0,0 +1,77 @@ +import 'package:intl/intl.dart'; + +class DateTimeUtils { + static const int countdownDays = 1356; + + static DateTime calculateEndDate(DateTime startDate) { + return startDate.add(const Duration(days: countdownDays)); + } + + static Duration calculateRemainingTime(DateTime endDate) { + return endDate.difference(DateTime.now()); + } + + static String formatCountdown(Duration duration) { + final days = duration.inDays; + final hours = duration.inHours % 24; + final minutes = duration.inMinutes % 60; + final seconds = duration.inSeconds % 60; + + return '${days}d ${hours}h ${minutes}m ${seconds}s'; + } + + static String formatCountdownCompact(Duration duration) { + final days = duration.inDays; + final hours = duration.inHours % 24; + final minutes = duration.inMinutes % 60; + + if (days > 0) { + return '${days}d ${hours}h'; + } else if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } + + static double calculateProgress(DateTime startDate, DateTime endDate) { + final now = DateTime.now(); + final totalDuration = endDate.difference(startDate).inSeconds; + final elapsedDuration = now.difference(startDate).inSeconds; + + if (elapsedDuration >= totalDuration) { + return 1.0; + } + + return elapsedDuration / totalDuration; + } + + static String formatDate(DateTime date) { + return DateFormat('MMM dd, yyyy').format(date); + } + + static String formatDateTime(DateTime dateTime) { + return DateFormat('MMM dd, yyyy • HH:mm').format(dateTime); + } + + static String formatRelativeTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inSeconds < 60) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return formatDate(dateTime); + } + } + + static bool isCountdownFinished(DateTime endDate) { + return DateTime.now().isAfter(endDate); + } +} diff --git a/lifetimer/lib/core/utils/validators.dart b/lifetimer/lib/core/utils/validators.dart new file mode 100644 index 0000000..970c32d --- /dev/null +++ b/lifetimer/lib/core/utils/validators.dart @@ -0,0 +1,87 @@ +class Validators { + static String? validateEmail(String? value) { + if (value == null || value.isEmpty) { + return 'Email is required'; + } + + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(value)) { + return 'Please enter a valid email address'; + } + + return null; + } + + static String? validatePassword(String? value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'Password must be at least 6 characters'; + } + + return null; + } + + static String? validateUsername(String? value) { + if (value == null || value.isEmpty) { + return 'Username is required'; + } + + if (value.length < 3) { + return 'Username must be at least 3 characters'; + } + + if (value.length > 20) { + return 'Username must not exceed 20 characters'; + } + + final usernameRegex = RegExp(r'^[a-zA-Z0-9_]+$'); + if (!usernameRegex.hasMatch(value)) { + return 'Username can only contain letters, numbers, and underscores'; + } + + return null; + } + + static String? validateGoalTitle(String? value) { + if (value == null || value.isEmpty) { + return 'Goal title is required'; + } + + if (value.length > 100) { + return 'Goal title must not exceed 100 characters'; + } + + return null; + } + + static String? validateGoalDescription(String? value) { + if (value != null && value.length > 500) { + return 'Description must not exceed 500 characters'; + } + + return null; + } + + static String? validateGoalProgress(int? value) { + if (value == null) { + return 'Progress is required'; + } + + if (value < 0 || value > 100) { + return 'Progress must be between 0 and 100'; + } + + return null; + } + + static String? validateRequired(String? value, String fieldName) { + if (value == null || value.isEmpty) { + return '$fieldName is required'; + } + + return null; + } +} diff --git a/lifetimer/lib/core/widgets/app_scaffold.dart b/lifetimer/lib/core/widgets/app_scaffold.dart new file mode 100644 index 0000000..0f06e9a --- /dev/null +++ b/lifetimer/lib/core/widgets/app_scaffold.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class AppScaffold extends StatelessWidget { + final String? title; + final Widget body; + final List? actions; + final Widget? floatingActionButton; + final bool showBackButton; + final VoidCallback? onBackPressed; + final Widget? bottomNavigationBar; + + const AppScaffold({ + super.key, + this.title, + required this.body, + this.actions, + this.floatingActionButton, + this.showBackButton = false, + this.onBackPressed, + this.bottomNavigationBar, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: title != null + ? AppBar( + title: Text(title!), + centerTitle: true, + automaticallyImplyLeading: showBackButton, + leading: showBackButton + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBackPressed ?? () => Navigator.pop(context), + ) + : null, + actions: actions, + ) + : null, + body: SafeArea(child: body), + floatingActionButton: floatingActionButton, + bottomNavigationBar: bottomNavigationBar, + ); + } +} diff --git a/lifetimer/lib/core/widgets/bottom_nav_scaffold.dart b/lifetimer/lib/core/widgets/bottom_nav_scaffold.dart new file mode 100644 index 0000000..54c6a8c --- /dev/null +++ b/lifetimer/lib/core/widgets/bottom_nav_scaffold.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class BottomNavScaffold extends StatefulWidget { + final Widget child; + + const BottomNavScaffold({ + super.key, + required this.child, + }); + + @override + State createState() => _BottomNavScaffoldState(); +} + +class _BottomNavScaffoldState extends State { + int _currentIndex = 0; + + final List _destinations = const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.flag_outlined), + selectedIcon: Icon(Icons.flag), + label: 'Goals', + ), + NavigationDestination( + icon: Icon(Icons.people_outline), + selectedIcon: Icon(Icons.people), + label: 'Social', + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profile', + ), + ]; + + void _onDestinationSelected(int index) { + setState(() { + _currentIndex = index; + }); + + switch (index) { + case 0: + context.go('/home'); + break; + case 1: + context.go('/goals'); + break; + case 2: + context.go('/social'); + break; + case 3: + context.go('/profile'); + break; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: widget.child, + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _onDestinationSelected, + destinations: _destinations, + ), + ); + } +} diff --git a/lifetimer/lib/core/widgets/empty_state.dart b/lifetimer/lib/core/widgets/empty_state.dart new file mode 100644 index 0000000..bad60fb --- /dev/null +++ b/lifetimer/lib/core/widgets/empty_state.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final String? actionLabel; + final VoidCallback? onAction; + + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 80, + color: Theme.of(context).colorScheme.primary.withOpacity(0.5), + ), + const SizedBox(height: 24), + Text( + title, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 24), + ElevatedButton( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lifetimer/lib/core/widgets/loading_indicator.dart b/lifetimer/lib/core/widgets/loading_indicator.dart new file mode 100644 index 0000000..43748a0 --- /dev/null +++ b/lifetimer/lib/core/widgets/loading_indicator.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class LoadingIndicator extends StatelessWidget { + final String? message; + + const LoadingIndicator({ + super.key, + this.message, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ), + ); + } +} diff --git a/lifetimer/lib/data/models/activity_model.dart b/lifetimer/lib/data/models/activity_model.dart new file mode 100644 index 0000000..76a90bf --- /dev/null +++ b/lifetimer/lib/data/models/activity_model.dart @@ -0,0 +1,56 @@ +import 'package:equatable/equatable.dart'; + +class Activity extends Equatable { + final String id; + final String userId; + final String type; + final Map? payload; + final DateTime createdAt; + + const Activity({ + required this.id, + required this.userId, + required this.type, + this.payload, + required this.createdAt, + }); + + Activity copyWith({ + String? id, + String? userId, + String? type, + Map? payload, + DateTime? createdAt, + }) { + return Activity( + id: id ?? this.id, + userId: userId ?? this.userId, + type: type ?? this.type, + payload: payload ?? this.payload, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'type': type, + 'payload': payload, + 'created_at': createdAt.toIso8601String(), + }; + } + + factory Activity.fromJson(Map json) { + return Activity( + id: json['id'] as String, + userId: json['user_id'] as String, + type: json['type'] as String, + payload: json['payload'] as Map?, + createdAt: DateTime.parse(json['created_at'] as String), + ); + } + + @override + List get props => [id, userId, type, payload, createdAt]; +} diff --git a/lifetimer/lib/data/models/goal_model.dart b/lifetimer/lib/data/models/goal_model.dart index f2556eb..fd6f977 100644 --- a/lifetimer/lib/data/models/goal_model.dart +++ b/lifetimer/lib/data/models/goal_model.dart @@ -78,4 +78,38 @@ class Goal extends Equatable { createdAt, updatedAt, ]; + + Map toJson() { + return { + 'id': id, + 'owner_id': ownerId, + 'title': title, + 'description': description, + 'progress': progress, + 'location_lat': locationLat, + 'location_lng': locationLng, + 'location_name': locationName, + 'image_url': imageUrl, + 'completed': completed, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + factory Goal.fromJson(Map json) { + return Goal( + id: json['id'] as String, + ownerId: json['owner_id'] as String, + title: json['title'] as String, + description: json['description'] as String?, + progress: json['progress'] as int? ?? 0, + locationLat: json['location_lat'] as double?, + locationLng: json['location_lng'] as double?, + locationName: json['location_name'] as String?, + imageUrl: json['image_url'] as String?, + completed: json['completed'] as bool? ?? false, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + } } diff --git a/lifetimer/lib/data/models/goal_step_model.dart b/lifetimer/lib/data/models/goal_step_model.dart new file mode 100644 index 0000000..7be4b71 --- /dev/null +++ b/lifetimer/lib/data/models/goal_step_model.dart @@ -0,0 +1,62 @@ +import 'package:equatable/equatable.dart'; + +class GoalStep extends Equatable { + final String id; + final String goalId; + final String title; + final bool isDone; + final int orderIndex; + final DateTime createdAt; + + const GoalStep({ + required this.id, + required this.goalId, + required this.title, + required this.isDone, + required this.orderIndex, + required this.createdAt, + }); + + GoalStep copyWith({ + String? id, + String? goalId, + String? title, + bool? isDone, + int? orderIndex, + DateTime? createdAt, + }) { + return GoalStep( + id: id ?? this.id, + goalId: goalId ?? this.goalId, + title: title ?? this.title, + isDone: isDone ?? this.isDone, + orderIndex: orderIndex ?? this.orderIndex, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'goal_id': goalId, + 'title': title, + 'is_done': isDone, + 'order_index': orderIndex, + 'created_at': createdAt.toIso8601String(), + }; + } + + factory GoalStep.fromJson(Map json) { + return GoalStep( + id: json['id'] as String, + goalId: json['goal_id'] as String, + title: json['title'] as String, + isDone: json['is_done'] as bool? ?? false, + orderIndex: json['order_index'] as int? ?? 0, + createdAt: DateTime.parse(json['created_at'] as String), + ); + } + + @override + List get props => [id, goalId, title, isDone, orderIndex, createdAt]; +} diff --git a/lifetimer/lib/data/models/user_model.dart b/lifetimer/lib/data/models/user_model.dart index a0fe255..d2a5f2d 100644 --- a/lifetimer/lib/data/models/user_model.dart +++ b/lifetimer/lib/data/models/user_model.dart @@ -76,4 +76,38 @@ class User extends Equatable { createdAt, updatedAt, ]; + + Map toJson() { + return { + 'id': id, + 'username': username, + 'email': email, + 'avatar_url': avatarUrl, + 'bio': bio, + 'is_public_profile': isPublicProfile, + 'countdown_start_date': countdownStartDate?.toIso8601String(), + 'countdown_end_date': countdownEndDate?.toIso8601String(), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + username: json['username'] as String, + email: json['email'] as String, + avatarUrl: json['avatar_url'] as String?, + bio: json['bio'] as String?, + isPublicProfile: json['is_public_profile'] as bool? ?? false, + countdownStartDate: json['countdown_start_date'] != null + ? DateTime.parse(json['countdown_start_date'] as String) + : null, + countdownEndDate: json['countdown_end_date'] != null + ? DateTime.parse(json['countdown_end_date'] as String) + : null, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + } } diff --git a/lifetimer/lib/data/repositories/countdown_repository.dart b/lifetimer/lib/data/repositories/countdown_repository.dart new file mode 100644 index 0000000..4f38c10 --- /dev/null +++ b/lifetimer/lib/data/repositories/countdown_repository.dart @@ -0,0 +1,62 @@ +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import '../models/user_model.dart' as app; +import '../../core/utils/date_time_utils.dart'; +import '../../core/errors/failure.dart'; + +class CountdownRepository { + final supabase.SupabaseClient _client; + + CountdownRepository(this._client); + + Future startCountdown(String userId) async { + try { + final startDate = DateTime.now(); + final endDate = DateTimeUtils.calculateEndDate(startDate); + + final response = await _client + .from('users') + .update({ + 'countdown_start_date': startDate.toIso8601String(), + 'countdown_end_date': endDate.toIso8601String(), + 'updated_at': DateTime.now().toIso8601String(), + }) + .eq('id', userId) + .select() + .single(); + + return app.User.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future getCountdownInfo(String userId) async { + try { + final response = await _client + .from('users') + .select() + .eq('id', userId) + .single(); + + return app.User.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future hasCountdownStarted(String userId) async { + try { + final user = await getCountdownInfo(userId); + return user.countdownStartDate != null; + } catch (e) { + throw _handleError(e); + } + } + + Failure _handleError(dynamic error) { + if (error is supabase.PostgrestException) { + return ServerFailure(error.message); + } + return UnknownFailure(error.toString()); + } +} diff --git a/lifetimer/lib/data/repositories/goals_repository.dart b/lifetimer/lib/data/repositories/goals_repository.dart new file mode 100644 index 0000000..1c3616d --- /dev/null +++ b/lifetimer/lib/data/repositories/goals_repository.dart @@ -0,0 +1,153 @@ +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import '../models/goal_model.dart'; +import '../models/goal_step_model.dart'; +import '../../core/errors/failure.dart'; + +class GoalsRepository { + final supabase.SupabaseClient _client; + static const int maxGoals = 20; + + GoalsRepository(this._client); + + Future> getGoals(String userId) async { + try { + final response = await _client + .from('goals') + .select() + .eq('owner_id', userId) + .order('created_at', ascending: false); + + return (response as List).map((json) => Goal.fromJson(json)).toList(); + } catch (e) { + throw _handleError(e); + } + } + + Future getGoal(String goalId) async { + try { + final response = await _client + .from('goals') + .select() + .eq('id', goalId) + .single(); + + return Goal.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future createGoal(Goal goal) async { + try { + final response = await _client + .from('goals') + .insert(goal.toJson()) + .select() + .single(); + + return Goal.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future updateGoal(Goal goal) async { + try { + final updates = goal.toJson(); + updates['updated_at'] = DateTime.now().toIso8601String(); + + final response = await _client + .from('goals') + .update(updates) + .eq('id', goal.id) + .select() + .single(); + + return Goal.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future deleteGoal(String goalId) async { + try { + await _client.from('goals').delete().eq('id', goalId); + } catch (e) { + throw _handleError(e); + } + } + + Future> getGoalSteps(String goalId) async { + try { + final response = await _client + .from('goal_steps') + .select() + .eq('goal_id', goalId) + .order('order_index', ascending: true); + + return (response as List).map((json) => GoalStep.fromJson(json)).toList(); + } catch (e) { + throw _handleError(e); + } + } + + Future createGoalStep(GoalStep step) async { + try { + final response = await _client + .from('goal_steps') + .insert(step.toJson()) + .select() + .single(); + + return GoalStep.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future updateGoalStep(GoalStep step) async { + try { + final response = await _client + .from('goal_steps') + .update(step.toJson()) + .eq('id', step.id) + .select() + .single(); + + return GoalStep.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future deleteGoalStep(String stepId) async { + try { + await _client.from('goal_steps').delete().eq('id', stepId); + } catch (e) { + throw _handleError(e); + } + } + + Future getGoalsCount(String userId) async { + try { + final response = await _client + .from('goals') + .select('id') + .eq('owner_id', userId); + + return (response as List).length; + } catch (e) { + throw _handleError(e); + } + } + + Failure _handleError(dynamic error) { + if (error is supabase.PostgrestException) { + if (error.code == '23505') { + return const ValidationFailure('A goal with this title already exists'); + } + return ServerFailure(error.message); + } + return UnknownFailure(error.toString()); + } +} diff --git a/lifetimer/lib/data/repositories/notifications_repository.dart b/lifetimer/lib/data/repositories/notifications_repository.dart new file mode 100644 index 0000000..1fcf0d1 --- /dev/null +++ b/lifetimer/lib/data/repositories/notifications_repository.dart @@ -0,0 +1,156 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:timezone/data/latest.dart' as tz_data; + +class NotificationsRepository { + final FlutterLocalNotificationsPlugin _notificationsPlugin; + + NotificationsRepository() : _notificationsPlugin = FlutterLocalNotificationsPlugin() { + _initialize(); + } + + Future _initialize() async { + tz_data.initializeTimeZones(); + + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings(); + + const initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _notificationsPlugin.initialize(initSettings); + } + + Future showNotification({ + required int id, + required String title, + required String body, + String? payload, + }) async { + const androidDetails = AndroidNotificationDetails( + 'lifetimer_channel', + 'LifeTimer Notifications', + channelDescription: 'Notifications for LifeTimer app', + importance: Importance.high, + priority: Priority.high, + ); + + const iosDetails = DarwinNotificationDetails(); + + const notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notificationsPlugin.show( + id, + title, + body, + notificationDetails, + payload: payload, + ); + } + + Future scheduleNotification({ + required int id, + required String title, + required String body, + required DateTime scheduledDate, + String? payload, + }) async { + const androidDetails = AndroidNotificationDetails( + 'lifetimer_channel', + 'LifeTimer Notifications', + channelDescription: 'Notifications for LifeTimer app', + importance: Importance.high, + priority: Priority.high, + ); + + const iosDetails = DarwinNotificationDetails(); + + const notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notificationsPlugin.zonedSchedule( + id, + title, + body, + tz.TZDateTime.from(scheduledDate, tz.local), + notificationDetails, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + payload: payload, + ); + } + + Future scheduleDailyReminder({ + required int id, + required String title, + required String body, + required int hour, + required int minute, + }) async { + const androidDetails = AndroidNotificationDetails( + 'lifetimer_daily_channel', + 'Daily Reminders', + channelDescription: 'Daily reminder notifications', + importance: Importance.high, + priority: Priority.high, + ); + + const iosDetails = DarwinNotificationDetails(); + + const notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notificationsPlugin.zonedSchedule( + id, + title, + body, + _nextInstanceOfTime(hour, minute), + notificationDetails, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.time, + payload: 'daily_reminder', + ); + } + + Future cancelNotification(int id) async { + await _notificationsPlugin.cancel(id); + } + + Future cancelAllNotifications() async { + await _notificationsPlugin.cancelAll(); + } + + Future> getPendingNotifications() async { + return await _notificationsPlugin.pendingNotificationRequests(); + } + + tz.TZDateTime _nextInstanceOfTime(int hour, int minute) { + final now = tz.TZDateTime.now(tz.local); + final scheduledDate = tz.TZDateTime( + tz.local, + now.year, + now.month, + now.day, + hour, + minute, + 0, + ); + + if (scheduledDate.isBefore(now)) { + return scheduledDate.add(const Duration(days: 1)); + } + + return scheduledDate; + } + +} diff --git a/lifetimer/lib/data/repositories/social_repository.dart b/lifetimer/lib/data/repositories/social_repository.dart new file mode 100644 index 0000000..a355825 --- /dev/null +++ b/lifetimer/lib/data/repositories/social_repository.dart @@ -0,0 +1,154 @@ +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import '../models/user_model.dart' as app; +import '../models/activity_model.dart'; +import '../../core/errors/failure.dart'; + +class SocialRepository { + final supabase.SupabaseClient _client; + + SocialRepository(this._client); + + Future followUser(String userId, String targetUserId) async { + try { + await _client.from('followers').insert({ + 'user_id': targetUserId, + 'follower_id': userId, + 'created_at': DateTime.now().toIso8601String(), + }); + } catch (e) { + throw _handleError(e); + } + } + + Future unfollowUser(String userId, String targetUserId) async { + try { + await _client + .from('followers') + .delete() + .eq('user_id', targetUserId) + .eq('follower_id', userId); + } catch (e) { + throw _handleError(e); + } + } + + Future isFollowing(String userId, String targetUserId) async { + try { + final response = await _client + .from('followers') + .select('id') + .eq('user_id', targetUserId) + .eq('follower_id', userId) + .maybeSingle(); + + return response != null; + } catch (e) { + throw _handleError(e); + } + } + + Future> getFollowers(String userId) async { + try { + final response = await _client + .from('followers') + .select('follower_id, users!followers_follower_id_fkey(*)') + .eq('user_id', userId); + + return (response as List) + .map((json) => app.User.fromJson(json['users'])) + .toList(); + } catch (e) { + throw _handleError(e); + } + } + + Future> getFollowing(String userId) async { + try { + final response = await _client + .from('followers') + .select('user_id, users!followers_user_id_fkey(*)') + .eq('follower_id', userId); + + return (response as List) + .map((json) => app.User.fromJson(json['users'])) + .toList(); + } catch (e) { + throw _handleError(e); + } + } + + Future> getActivityFeed(String userId) async { + try { + final response = await _client + .from('activities') + .select() + .eq('user_id', userId) + .order('created_at', ascending: false) + .limit(50); + + return (response as List).map((json) => Activity.fromJson(json)).toList(); + } catch (e) { + throw _handleError(e); + } + } + + Future logActivity({ + required String userId, + required String type, + Map? payload, + }) async { + try { + final response = await _client + .from('activities') + .insert({ + 'user_id': userId, + 'type': type, + 'payload': payload, + 'created_at': DateTime.now().toIso8601String(), + }) + .select() + .single(); + + return Activity.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future> getLeaderboard({ + required String sortBy, + int limit = 50, + }) async { + try { + String orderBy; + switch (sortBy) { + case 'goals_completed': + orderBy = 'goals_completed_count'; + break; + case 'streak': + orderBy = 'streak_days'; + break; + default: + orderBy = 'created_at'; + } + + final response = await _client + .from('users') + .select() + .eq('is_public_profile', true) + .order(orderBy, ascending: false) + .limit(limit); + + return (response as List).map((json) => app.User.fromJson(json)).toList(); + } catch (e) { + throw _handleError(e); + } + } + + Failure _handleError(dynamic error) { + if (error is supabase.PostgrestException) { + return ServerFailure(error.message); + } + return UnknownFailure(error.toString()); + } +} diff --git a/lifetimer/lib/data/repositories/user_repository.dart b/lifetimer/lib/data/repositories/user_repository.dart new file mode 100644 index 0000000..049b878 --- /dev/null +++ b/lifetimer/lib/data/repositories/user_repository.dart @@ -0,0 +1,83 @@ +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import '../models/user_model.dart' as app; +import '../../core/errors/failure.dart'; + +class UserRepository { + final supabase.SupabaseClient _client; + + UserRepository(this._client); + + Future getProfile(String userId) async { + try { + final response = await _client + .from('users') + .select() + .eq('id', userId) + .single(); + + return app.User.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future updateProfile({ + required String userId, + String? username, + String? avatarUrl, + String? bio, + bool? isPublicProfile, + }) async { + try { + final updates = {}; + if (username != null) updates['username'] = username; + if (avatarUrl != null) updates['avatar_url'] = avatarUrl; + if (bio != null) updates['bio'] = bio; + if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile; + updates['updated_at'] = DateTime.now().toIso8601String(); + + final response = await _client + .from('users') + .update(updates) + .eq('id', userId) + .select() + .single(); + + return app.User.fromJson(response); + } catch (e) { + throw _handleError(e); + } + } + + Future isUsernameAvailable(String username) async { + try { + final response = await _client + .from('users') + .select('id') + .eq('username', username) + .maybeSingle(); + + return response == null; + } catch (e) { + throw _handleError(e); + } + } + + Future deleteAccount(String userId) async { + try { + await _client.from('users').delete().eq('id', userId); + } catch (e) { + throw _handleError(e); + } + } + + Failure _handleError(dynamic error) { + if (error is supabase.PostgrestException) { + if (error.code == '23505') { + return const ValidationFailure('Username already taken'); + } + return ServerFailure(error.message); + } + return UnknownFailure(error.toString()); + } +}