Added core data models, repositories, and utilities

This commit is contained in:
Tomas Dvorak
2026-01-03 18:37:48 +01:00
parent 1639de69d4
commit 572fbb971c
17 changed files with 1248 additions and 0 deletions
@@ -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;
}
}
+37
View File
@@ -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);
}
@@ -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);
}
}
+87
View File
@@ -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;
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
class AppScaffold extends StatelessWidget {
final String? title;
final Widget body;
final List<Widget>? 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,
);
}
}
@@ -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<BottomNavScaffold> createState() => _BottomNavScaffoldState();
}
class _BottomNavScaffoldState extends State<BottomNavScaffold> {
int _currentIndex = 0;
final List<NavigationDestination> _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,
),
);
}
}
@@ -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!),
),
],
],
),
),
);
}
}
@@ -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,
),
],
],
),
);
}
}
@@ -0,0 +1,56 @@
import 'package:equatable/equatable.dart';
class Activity extends Equatable {
final String id;
final String userId;
final String type;
final Map<String, dynamic>? 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<String, dynamic>? 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<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'type': type,
'payload': payload,
'created_at': createdAt.toIso8601String(),
};
}
factory Activity.fromJson(Map<String, dynamic> json) {
return Activity(
id: json['id'] as String,
userId: json['user_id'] as String,
type: json['type'] as String,
payload: json['payload'] as Map<String, dynamic>?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
@override
List<Object?> get props => [id, userId, type, payload, createdAt];
}
+34
View File
@@ -78,4 +78,38 @@ class Goal extends Equatable {
createdAt, createdAt,
updatedAt, updatedAt,
]; ];
Map<String, dynamic> 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<String, dynamic> 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),
);
}
} }
@@ -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<String, dynamic> toJson() {
return {
'id': id,
'goal_id': goalId,
'title': title,
'is_done': isDone,
'order_index': orderIndex,
'created_at': createdAt.toIso8601String(),
};
}
factory GoalStep.fromJson(Map<String, dynamic> 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<Object?> get props => [id, goalId, title, isDone, orderIndex, createdAt];
}
+34
View File
@@ -76,4 +76,38 @@ class User extends Equatable {
createdAt, createdAt,
updatedAt, updatedAt,
]; ];
Map<String, dynamic> 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<String, dynamic> 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),
);
}
} }
@@ -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<app.User> 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<app.User> 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<bool> 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());
}
}
@@ -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<List<Goal>> 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<Goal> 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<Goal> 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<Goal> 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<void> deleteGoal(String goalId) async {
try {
await _client.from('goals').delete().eq('id', goalId);
} catch (e) {
throw _handleError(e);
}
}
Future<List<GoalStep>> 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<GoalStep> 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<GoalStep> 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<void> deleteGoalStep(String stepId) async {
try {
await _client.from('goal_steps').delete().eq('id', stepId);
} catch (e) {
throw _handleError(e);
}
}
Future<int> 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());
}
}
@@ -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<void> _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<void> 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<void> 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<void> 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<void> cancelNotification(int id) async {
await _notificationsPlugin.cancel(id);
}
Future<void> cancelAllNotifications() async {
await _notificationsPlugin.cancelAll();
}
Future<List<PendingNotificationRequest>> 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;
}
}
@@ -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<void> 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<void> 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<bool> 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<List<app.User>> 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<List<app.User>> 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<List<Activity>> 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<Activity> logActivity({
required String userId,
required String type,
Map<String, dynamic>? 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<List<app.User>> 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());
}
}
@@ -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<app.User> 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<app.User> updateProfile({
required String userId,
String? username,
String? avatarUrl,
String? bio,
bool? isPublicProfile,
}) async {
try {
final updates = <String, dynamic>{};
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<bool> 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<void> 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());
}
}