mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 12:22:56 +00:00
feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0 Major changes: - Implemented complete Flutter app structure with all core features - Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features - Integrated Supabase backend with authentication and data repositories - Added offline support with Hive caching and local storage - Implemented comprehensive routing with go_router - Added location services with Google Maps integration - Implemented notifications and home widget support - Added voice recording capabilities and AI chat features - Created comprehensive test suite and documentation - Added Android and iOS platform configurations - Implemented achievements system and social features - Added calendar integration and bucket list functionality This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../models/achievement_model.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
|
||||
class AchievementsRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
|
||||
AchievementsRepository(this._client);
|
||||
|
||||
static final List<Achievement> _availableAchievements = [
|
||||
Achievement(
|
||||
id: 'first_goal',
|
||||
title: 'First Goal',
|
||||
description: 'Complete your first goal',
|
||||
icon: '🎯',
|
||||
type: AchievementType.firstGoal,
|
||||
threshold: 1,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'goals_5',
|
||||
title: '5 Goals Completed',
|
||||
description: 'Complete 5 goals',
|
||||
icon: '⭐',
|
||||
type: AchievementType.goalsCompleted5,
|
||||
threshold: 5,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'goals_10',
|
||||
title: '10 Goals Completed',
|
||||
description: 'Complete 10 goals',
|
||||
icon: '🌟',
|
||||
type: AchievementType.goalsCompleted10,
|
||||
threshold: 10,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'goals_20',
|
||||
title: '20 Goals Completed',
|
||||
description: 'Complete all 20 goals',
|
||||
icon: '💫',
|
||||
type: AchievementType.goalsCompleted20,
|
||||
threshold: 20,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'streak_7',
|
||||
title: '7 Day Streak',
|
||||
description: 'Update progress for 7 consecutive days',
|
||||
icon: '🔥',
|
||||
type: AchievementType.streak7Days,
|
||||
threshold: 7,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'streak_30',
|
||||
title: '30 Day Streak',
|
||||
description: 'Update progress for 30 consecutive days',
|
||||
icon: '🏆',
|
||||
type: AchievementType.streak30Days,
|
||||
threshold: 30,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_started',
|
||||
title: 'Challenge Started',
|
||||
description: 'Start your 1356-day countdown',
|
||||
icon: '🚀',
|
||||
type: AchievementType.countdownStarted,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_25',
|
||||
title: '25% Complete',
|
||||
description: 'Reach 25% of your countdown',
|
||||
icon: '📊',
|
||||
type: AchievementType.countdown25Percent,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_50',
|
||||
title: '50% Complete',
|
||||
description: 'Reach 50% of your countdown',
|
||||
icon: '📈',
|
||||
type: AchievementType.countdown50Percent,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_75',
|
||||
title: '75% Complete',
|
||||
description: 'Reach 75% of your countdown',
|
||||
icon: '📉',
|
||||
type: AchievementType.countdown75Percent,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_complete',
|
||||
title: 'Challenge Complete',
|
||||
description: 'Complete your 1356-day challenge',
|
||||
icon: '🎉',
|
||||
type: AchievementType.countdownCompleted,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'early_bird',
|
||||
title: 'Early Bird',
|
||||
description: 'Update progress before 8 AM',
|
||||
icon: '🌅',
|
||||
type: AchievementType.earlyBird,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'night_owl',
|
||||
title: 'Night Owl',
|
||||
description: 'Update progress after 10 PM',
|
||||
icon: '🌙',
|
||||
type: AchievementType.nightOwl,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'social_butterfly',
|
||||
title: 'Social Butterfly',
|
||||
description: 'Follow 10 users',
|
||||
icon: '🦋',
|
||||
type: AchievementType.socialButterfly,
|
||||
threshold: 10,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
Future<List<Achievement>> getAvailableAchievements() async {
|
||||
return _availableAchievements;
|
||||
}
|
||||
|
||||
Future<List<Achievement>> getUserAchievements(String userId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('user_achievements')
|
||||
.select('*, achievements(*)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return response.map((json) {
|
||||
final achievementData = json['achievements'] as Map<String, dynamic>;
|
||||
return Achievement.fromJson(achievementData).copyWith(
|
||||
isUnlocked: true,
|
||||
unlockedAt: DateTime.parse(json['unlocked_at'] as String),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
if (e is supabase.PostgrestException && e.code == '42P01') {
|
||||
return [];
|
||||
}
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlockAchievement(String userId, String achievementId) async {
|
||||
try {
|
||||
await _client.from('user_achievements').insert({
|
||||
'user_id': userId,
|
||||
'achievement_id': achievementId,
|
||||
'unlocked_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Achievement?> checkAndUnlockAchievement(
|
||||
String userId,
|
||||
AchievementType type,
|
||||
int currentValue,
|
||||
) async {
|
||||
final achievement = _availableAchievements.firstWhere(
|
||||
(a) => a.type == type,
|
||||
);
|
||||
|
||||
if (achievement.threshold != null && currentValue >= achievement.threshold!) {
|
||||
final userAchievements = await getUserAchievements(userId);
|
||||
final alreadyUnlocked = userAchievements.any((a) => a.type == type);
|
||||
|
||||
if (!alreadyUnlocked) {
|
||||
await unlockAchievement(userId, achievement.id);
|
||||
return achievement.copyWith(isUnlocked: true, unlockedAt: DateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Failure _handleError(dynamic error) {
|
||||
if (error is supabase.PostgrestException) {
|
||||
return ServerFailure(error.message);
|
||||
}
|
||||
return UnknownFailure(error.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../models/user_model.dart';
|
||||
import '../../bootstrap/supabase_client.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' hide User;
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final SupabaseClient _client;
|
||||
final supabase.SupabaseClient _client;
|
||||
StreamSubscription<supabase.AuthState>? _authStateSubscription;
|
||||
|
||||
AuthRepository([SupabaseClient? client]) : _client = client ?? supabaseClient;
|
||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client ?? supabaseClient;
|
||||
|
||||
Stream<User?> get authStateChanges {
|
||||
return _client.auth.onAuthStateChange.map((data) {
|
||||
@@ -22,6 +27,48 @@ class AuthRepository {
|
||||
return user != null ? _mapSupabaseUserToAppUser(user) : null;
|
||||
}
|
||||
|
||||
bool get isAuthenticated => _client.auth.currentUser != null;
|
||||
|
||||
String? get currentUserId => _client.auth.currentUser?.id;
|
||||
|
||||
Future<bool> isSessionValid() async {
|
||||
final session = _client.auth.currentSession;
|
||||
if (session == null) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
final expiresAt = session.expiresAt;
|
||||
if (expiresAt == null) return true;
|
||||
|
||||
return now.isBefore(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
|
||||
}
|
||||
|
||||
Future<void> refreshSession() async {
|
||||
try {
|
||||
await _client.auth.refreshSession();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to refresh session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<supabase.Session?> getCurrentSession() async {
|
||||
return _client.auth.currentSession;
|
||||
}
|
||||
|
||||
void listenToAuthStateChanges(Function(User?) callback) {
|
||||
_authStateSubscription = _client.auth.onAuthStateChange.listen((data) {
|
||||
final session = data.session;
|
||||
if (session?.user != null) {
|
||||
callback(_mapSupabaseUserToAppUser(session!.user));
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_authStateSubscription?.cancel();
|
||||
}
|
||||
|
||||
Future<void> signInWithEmail(String email, String password) async {
|
||||
await _client.auth.signInWithPassword(email: email, password: password);
|
||||
}
|
||||
@@ -39,15 +86,58 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
Future<void> signInWithGoogle() async {
|
||||
// TODO: Implement Google OAuth
|
||||
// await _client.auth.signInWithOAuth(OAuthProvider.google);
|
||||
throw UnimplementedError('Google OAuth not implemented yet');
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
|
||||
final googleUser = await googleSignIn.signIn();
|
||||
if (googleUser == null) {
|
||||
throw Exception('Google sign-in was cancelled');
|
||||
}
|
||||
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final idToken = googleAuth.idToken;
|
||||
|
||||
if (idToken == null) {
|
||||
throw Exception('No ID token from Google sign-in');
|
||||
}
|
||||
|
||||
final response = await _client.auth.signInWithIdToken(
|
||||
provider: supabase.OAuthProvider.google,
|
||||
idToken: idToken,
|
||||
);
|
||||
|
||||
if (response.user != null) {
|
||||
await _ensureUserProfileExists(response.user!.id, response.user!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithApple() async {
|
||||
// TODO: Implement Apple OAuth
|
||||
// await _client.auth.signInWithOAuth(OAuthProvider.apple);
|
||||
throw UnimplementedError('Apple OAuth not implemented yet');
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [
|
||||
AppleIDAuthorizationScopes.email,
|
||||
AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
);
|
||||
|
||||
final identityToken = credential.identityToken;
|
||||
if (identityToken == null) {
|
||||
throw Exception('No identity token from Apple sign-in');
|
||||
}
|
||||
|
||||
final response = await _client.auth.signInWithIdToken(
|
||||
provider: supabase.OAuthProvider.apple,
|
||||
idToken: identityToken,
|
||||
accessToken: credential.authorizationCode,
|
||||
);
|
||||
|
||||
if (response.user != null) {
|
||||
await _ensureUserProfileExists(response.user!.id, response.user!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithGithub() async {
|
||||
await _client.auth.signInWithOAuth(
|
||||
supabase.OAuthProvider.github,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
@@ -82,7 +172,7 @@ class AuthRepository {
|
||||
|
||||
Future<User> _createUserProfile(String userId, String username, String email) async {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
|
||||
final response = await _client.from('users').insert({
|
||||
'id': userId,
|
||||
'username': username,
|
||||
@@ -94,6 +184,21 @@ class AuthRepository {
|
||||
return _mapSupabaseDataToUser(response);
|
||||
}
|
||||
|
||||
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
|
||||
final existingProfile = await _client
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingProfile == null) {
|
||||
final username = supabaseUser.userMetadata?['username'] ??
|
||||
'user_${userId.substring(0, 8)}';
|
||||
final email = supabaseUser.email ?? '';
|
||||
await _createUserProfile(userId, username, email);
|
||||
}
|
||||
}
|
||||
|
||||
User _mapSupabaseUserToAppUser(dynamic supabaseUser) {
|
||||
return User(
|
||||
id: supabaseUser.id,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
|
||||
import '../models/calendar_entry_model.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
|
||||
class CalendarRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
|
||||
CalendarRepository(this._client);
|
||||
|
||||
Future<List<CalendarEntry>> getEntriesForDate({
|
||||
required String userId,
|
||||
required DateTime date,
|
||||
}) async {
|
||||
try {
|
||||
final dateStr = date.toIso8601String().split('T').first;
|
||||
|
||||
final response = await _client
|
||||
.from('calendar_entries')
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
.eq('entry_date', dateStr)
|
||||
.order('created_at', ascending: true);
|
||||
|
||||
return (response as List)
|
||||
.map((json) => CalendarEntry.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CalendarEntry> addEntry({
|
||||
required String userId,
|
||||
required DateTime date,
|
||||
required String title,
|
||||
String? note,
|
||||
String entryType = 'note',
|
||||
String? goalId,
|
||||
}) async {
|
||||
try {
|
||||
final dateStr = date.toIso8601String().split('T').first;
|
||||
|
||||
final response = await _client
|
||||
.from('calendar_entries')
|
||||
.insert({
|
||||
'user_id': userId,
|
||||
'goal_id': goalId,
|
||||
'entry_date': dateStr,
|
||||
'title': title,
|
||||
'note': note,
|
||||
'entry_type': entryType,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return CalendarEntry.fromJson(response);
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Failure _handleError(dynamic error) {
|
||||
if (error is supabase.PostgrestException) {
|
||||
return ServerFailure(error.message);
|
||||
}
|
||||
return UnknownFailure(error.toString());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ class CountdownRepository {
|
||||
|
||||
Future<app.User> startCountdown(String userId) async {
|
||||
try {
|
||||
final user = await getCountdownInfo(userId);
|
||||
if (user.countdownStartDate != null) {
|
||||
throw const ValidationFailure('Countdown has already started and cannot be restarted');
|
||||
}
|
||||
|
||||
final startDate = DateTime.now();
|
||||
final endDate = DateTimeUtils.calculateEndDate(startDate);
|
||||
|
||||
|
||||
@@ -69,6 +69,21 @@ class GoalsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> canModifyGoals(String userId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('users')
|
||||
.select('countdown_start_date')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
final countdownStartDate = response['countdown_start_date'];
|
||||
return countdownStartDate == null;
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteGoal(String goalId) async {
|
||||
try {
|
||||
await _client.from('goals').delete().eq('id', goalId);
|
||||
|
||||
@@ -27,6 +27,10 @@ class UserRepository {
|
||||
String? avatarUrl,
|
||||
String? bio,
|
||||
bool? isPublicProfile,
|
||||
String? twitterHandle,
|
||||
String? instagramHandle,
|
||||
String? tiktokHandle,
|
||||
String? websiteUrl,
|
||||
}) async {
|
||||
try {
|
||||
final updates = <String, dynamic>{};
|
||||
@@ -34,6 +38,10 @@ class UserRepository {
|
||||
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
|
||||
if (bio != null) updates['bio'] = bio;
|
||||
if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile;
|
||||
if (twitterHandle != null) updates['twitter_handle'] = twitterHandle;
|
||||
if (instagramHandle != null) updates['instagram_handle'] = instagramHandle;
|
||||
if (tiktokHandle != null) updates['tiktok_handle'] = tiktokHandle;
|
||||
if (websiteUrl != null) updates['website_url'] = websiteUrl;
|
||||
updates['updated_at'] = DateTime.now().toIso8601String();
|
||||
|
||||
final response = await _client
|
||||
|
||||
Reference in New Issue
Block a user