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,
),
],
],
),
);
}
}