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:
Tomas Dvorak
2026-01-04 14:33:54 +01:00
parent 1a29315672
commit 37ffb93923
210 changed files with 29417 additions and 477 deletions
@@ -0,0 +1,19 @@
import 'package:home_widget/home_widget.dart';
class HomeScreenWidgetService {
static const androidWidgetProvider = 'NextCountdownWidgetProvider';
Future<void> updateNextCountdownWidget({
required String title,
required String timeLeft,
String? subtitle,
}) async {
await HomeWidget.saveWidgetData<String>('next_title', title);
await HomeWidget.saveWidgetData<String>('next_time_left', timeLeft);
if (subtitle != null) {
await HomeWidget.saveWidgetData<String>('next_subtitle', subtitle);
}
await HomeWidget.updateWidget(name: androidWidgetProvider);
}
}
@@ -0,0 +1,161 @@
// ignore_for_file: depend_on_referenced_packages
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:crypto/crypto.dart';
import 'dart:convert';
class ImageCacheService {
static const int _maxCacheSize = 50 * 1024 * 1024; // 50MB
static const Duration _cacheExpiry = Duration(days: 30);
static const int _maxConcurrentOperations = 3;
late Directory _cacheDir;
bool _initialized = false;
int _activeOperations = 0;
Future<void> init() async {
if (_initialized) return;
final appDir = await getApplicationDocumentsDirectory();
_cacheDir = Directory(path.join(appDir.path, 'image_cache'));
if (!await _cacheDir.exists()) {
await _cacheDir.create(recursive: true);
}
_initialized = true;
await _cleanupExpiredCache();
}
String _generateCacheKey(String url) {
final bytes = utf8.encode(url);
final digest = sha256.convert(bytes);
return digest.toString();
}
Future<File?> getCachedImage(String url) async {
if (!_initialized) await init();
final cacheKey = _generateCacheKey(url);
final cachedFile = File(path.join(_cacheDir.path, '$cacheKey.jpg'));
if (!await cachedFile.exists()) {
return null;
}
final stat = await cachedFile.stat();
final age = DateTime.now().difference(stat.modified);
if (age > _cacheExpiry) {
await cachedFile.delete();
return null;
}
return cachedFile;
}
Future<File> cacheImage(String url, Uint8List imageData) async {
if (!_initialized) await init();
// Limit concurrent operations to avoid overwhelming the system
while (_activeOperations >= _maxConcurrentOperations) {
await Future.delayed(const Duration(milliseconds: 10));
}
_activeOperations++;
try {
final cacheKey = _generateCacheKey(url);
final cachedFile = File(path.join(_cacheDir.path, '$cacheKey.jpg'));
await cachedFile.writeAsBytes(imageData);
await _enforceCacheSizeLimit();
return cachedFile;
} finally {
_activeOperations--;
}
}
Future<void> clearCache() async {
if (!_initialized) await init();
if (await _cacheDir.exists()) {
await _cacheDir.delete(recursive: true);
await _cacheDir.create(recursive: true);
}
}
Future<int> getCacheSize() async {
if (!_initialized) await init();
int totalSize = 0;
if (await _cacheDir.exists()) {
await for (final entity in _cacheDir.list()) {
if (entity is File) {
totalSize += await entity.length();
}
}
}
return totalSize;
}
Future<void> _cleanupExpiredCache() async {
if (!await _cacheDir.exists()) return;
final now = DateTime.now();
await for (final entity in _cacheDir.list()) {
if (entity is File) {
final stat = await entity.stat();
final age = now.difference(stat.modified);
if (age > _cacheExpiry) {
await entity.delete();
}
}
}
}
Future<void> _enforceCacheSizeLimit() async {
final currentSize = await getCacheSize();
if (currentSize <= _maxCacheSize) return;
final files = <File>[];
final fileStats = <File, FileStat>{};
await for (final entity in _cacheDir.list()) {
if (entity is File) {
files.add(entity);
fileStats[entity] = await entity.stat();
}
}
files.sort((a, b) {
final statA = fileStats[a]!;
final statB = fileStats[b]!;
return statA.modified.compareTo(statB.modified);
});
int sizeToRemove = currentSize - _maxCacheSize;
for (final file in files) {
if (sizeToRemove <= 0) break;
final fileSize = await file.length();
await file.delete();
sizeToRemove -= fileSize;
}
}
Future<void> dispose() async {
_initialized = false;
}
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class UnsplashImage {
final String id;
final String url;
final String fullUrl;
final String? description;
final String? photographer;
final String? photographerUrl;
UnsplashImage({
required this.id,
required this.url,
required this.fullUrl,
this.description,
this.photographer,
this.photographerUrl,
});
factory UnsplashImage.fromJson(Map<String, dynamic> json) {
final urls = json['urls'] as Map<String, dynamic>;
final user = json['user'] as Map<String, dynamic>?;
return UnsplashImage(
id: json['id'] as String,
url: urls['regular'] as String? ?? urls['small'] as String,
fullUrl: urls['full'] as String? ?? urls['regular'] as String,
description: json['description'] as String?,
photographer: user?['name'] as String?,
photographerUrl: user?['links']?['html'] as String?,
);
}
}
class ImageSearchService {
final String _accessKey;
final http.Client _client;
ImageSearchService({
required String accessKey,
http.Client? client,
}) : _accessKey = accessKey,
_client = client ?? http.Client();
Future<List<UnsplashImage>> searchImages({
required String query,
int perPage = 10,
String orientation = 'landscape',
}) async {
try {
final uri = Uri.https('api.unsplash.com', '/search/photos', {
'query': query,
'per_page': perPage.toString(),
'orientation': orientation,
});
final response = await _client.get(
uri,
headers: {
'Authorization': 'Client-ID $_accessKey',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final results = data['results'] as List;
return results
.map((json) => UnsplashImage.fromJson(json as Map<String, dynamic>))
.toList();
} else {
throw Exception('Failed to search images: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error searching images: $e');
}
}
Future<UnsplashImage?> getRandomImage({
String? query,
String orientation = 'landscape',
}) async {
try {
final params = <String, String>{
'orientation': orientation,
};
if (query != null) {
params['query'] = query;
}
final uri = Uri.https('api.unsplash.com', '/photos/random', params);
final response = await _client.get(
uri,
headers: {
'Authorization': 'Client-ID $_accessKey',
},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return UnsplashImage.fromJson(json);
} else {
throw Exception('Failed to get random image: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error getting random image: $e');
}
}
void dispose() {
_client.close();
}
}
@@ -0,0 +1,169 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../bootstrap/env.dart';
class ChatMessage {
final String content;
final String role;
final DateTime timestamp;
ChatMessage({
required this.content,
required this.role,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
Map<String, dynamic> toJson() {
return {
'content': content,
'role': role,
'timestamp': timestamp.toIso8601String(),
};
}
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
content: json['content'] as String,
role: json['role'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}
class MistralAIException implements Exception {
final String message;
final int? statusCode;
MistralAIException(this.message, [this.statusCode]);
@override
String toString() => 'MistralAIException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
}
class MistralAIService {
final String _apiKey;
final http.Client _client;
MistralAIService({
required String apiKey,
http.Client? client,
}) : _apiKey = apiKey,
_client = client ?? http.Client();
Future<String> chat({
required String message,
String model = Env.mistralChatModel,
List<ChatMessage>? conversationHistory,
String? userContext,
}) async {
try {
final messages = <Map<String, String>>[];
// Add system prompt for LifeTimer context
messages.add({
'role': 'system',
'content': '''You are an AI assistant for LifeTimer, a gamified life countdown app where users create a bucket list and start a 1356-day countdown.
Your role is to help users with:
1. Goal setting and bucket list inspiration
2. Motivation and encouragement
3. Life advice and productivity tips
4. Creative ideas for experiences
Be inspiring, practical, and encouraging. Keep responses concise but meaningful.
If user context is provided, use it to personalise your responses while respecting any stated privacy limitations.''',
});
// Add optional structured user context as a separate system message
if (userContext != null && userContext.trim().isNotEmpty) {
messages.add({
'role': 'system',
'content': 'Current user context for this conversation: ${userContext.trim()}',
});
}
// Add conversation history if provided
if (conversationHistory != null) {
final recentMessages = conversationHistory.length > 10
? conversationHistory.sublist(conversationHistory.length - 10)
: conversationHistory;
for (final msg in recentMessages) { // Keep last 10 messages for context
messages.add({
'role': msg.role,
'content': msg.content,
});
}
}
// Add current message
messages.add({
'role': 'user',
'content': message,
});
final uri = Uri.https('api.mistral.ai', '/v1/chat/completions');
final response = await _client.post(
uri,
headers: {
'Authorization': 'Bearer $_apiKey',
'Content-Type': 'application/json',
},
body: jsonEncode({
'model': model,
'messages': messages,
'max_tokens': 500,
'temperature': 0.7,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final choices = data['choices'] as List;
final firstChoice = choices.first as Map<String, dynamic>;
final message = firstChoice['message'] as Map<String, dynamic>;
return message['content'] as String;
} else {
throw MistralAIException(
'Failed to get chat response',
response.statusCode,
);
}
} catch (e) {
if (e is MistralAIException) rethrow;
throw MistralAIException('Error in chat: $e');
}
}
Future<String> transcribeAudio({
required String audioFilePath,
String model = Env.mistralVoiceModel,
}) async {
try {
final uri = Uri.https('api.mistral.ai', '/v1/audio/transcriptions');
final request = http.MultipartRequest('POST', uri)
..headers['Authorization'] = 'Bearer $_apiKey'
..fields['model'] = model
..files.add(await http.MultipartFile.fromPath('file', audioFilePath));
final response = await request.send();
if (response.statusCode == 200) {
final responseBody = await response.stream.bytesToString();
final data = jsonDecode(responseBody) as Map<String, dynamic>;
return data['text'] as String;
} else {
throw MistralAIException(
'Failed to transcribe audio',
response.statusCode,
);
}
} catch (e) {
if (e is MistralAIException) rethrow;
throw MistralAIException('Error in transcription: $e');
}
}
void dispose() {
_client.close();
}
}
@@ -0,0 +1,93 @@
import 'package:hive_flutter/hive_flutter.dart';
import '../models/cached_goal_model.dart';
class OfflineCacheService {
static const String _goalsBoxName = 'cached_goals';
static const String _userBoxName = 'cached_user';
static const String _countdownBoxName = 'cached_countdown';
late Box<CachedGoal> _goalsBox;
late Box _userBox;
late Box _countdownBox;
Future<void> init() async {
await Hive.initFlutter();
if (!Hive.isAdapterRegistered(0)) {
Hive.registerAdapter(CachedGoalAdapter());
}
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
_userBox = await Hive.openBox(_userBoxName);
_countdownBox = await Hive.openBox(_countdownBoxName);
}
Future<void> cacheGoals(List<CachedGoal> goals) async {
await _goalsBox.clear();
for (var goal in goals) {
await _goalsBox.put(goal.id, goal);
}
}
Future<List<CachedGoal>> getCachedGoals() async {
return _goalsBox.values.toList();
}
Future<CachedGoal?> getCachedGoal(String goalId) async {
return _goalsBox.get(goalId);
}
Future<void> cacheGoal(CachedGoal goal) async {
await _goalsBox.put(goal.id, goal);
}
Future<void> deleteCachedGoal(String goalId) async {
await _goalsBox.delete(goalId);
}
Future<void> markGoalAsDirty(String goalId) async {
final goal = _goalsBox.get(goalId);
if (goal != null) {
await _goalsBox.put(goalId, goal.copyWith(isDirty: true));
}
}
Future<List<CachedGoal>> getDirtyGoals() async {
return _goalsBox.values.where((goal) => goal.isDirty).toList();
}
Future<void> clearDirtyFlag(String goalId) async {
final goal = _goalsBox.get(goalId);
if (goal != null) {
await _goalsBox.put(goalId, goal.copyWith(isDirty: false));
}
}
Future<void> cacheUserData(Map<String, dynamic> userData) async {
await _userBox.putAll(userData);
}
Future<Map<String, dynamic>> getCachedUserData() async {
return Map<String, dynamic>.from(_userBox.toMap());
}
Future<void> cacheCountdownData(Map<String, dynamic> countdownData) async {
await _countdownBox.putAll(countdownData);
}
Future<Map<String, dynamic>> getCachedCountdownData() async {
return Map<String, dynamic>.from(_countdownBox.toMap());
}
Future<void> clearAllCache() async {
await _goalsBox.clear();
await _userBox.clear();
await _countdownBox.clear();
}
Future<void> close() async {
await _goalsBox.close();
await _userBox.close();
await _countdownBox.close();
}
}
@@ -0,0 +1,86 @@
import 'dart:developer' as developer;
import 'package:hive/hive.dart';
import '../models/offline_mutation_model.dart';
class OfflineMutationQueue {
static const String _mutationsBoxName = 'offline_mutations';
late Box<OfflineMutation> _mutationsBox;
Future<void> init() async {
_mutationsBox = await Hive.openBox<OfflineMutation>(_mutationsBoxName);
}
Future<void> enqueueMutation(OfflineMutation mutation) async {
await _mutationsBox.put(mutation.id, mutation);
}
Future<List<OfflineMutation>> getPendingMutations() async {
return _mutationsBox.values
.where((mutation) => !mutation.isSynced)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
Future<void> markMutationAsSynced(String mutationId) async {
final mutation = _mutationsBox.get(mutationId);
if (mutation != null) {
await _mutationsBox.put(
mutationId,
mutation.copyWith(
isSynced: true,
syncedAt: DateTime.now(),
),
);
}
}
Future<void> removeSyncedMutations() async {
final syncedMutations = _mutationsBox.values
.where((mutation) => mutation.isSynced)
.toList();
for (var mutation in syncedMutations) {
await _mutationsBox.delete(mutation.id);
}
}
Future<void> clearMutation(String mutationId) async {
await _mutationsBox.delete(mutationId);
}
Future<void> clearAllMutations() async {
await _mutationsBox.clear();
}
Future<int> getPendingMutationCount() async {
return _mutationsBox.values.where((m) => !m.isSynced).length;
}
Future<void> syncPendingMutations({
required Future<void> Function(OfflineMutation) onSync,
}) async {
final pendingMutations = await getPendingMutations();
for (var mutation in pendingMutations) {
try {
await onSync(mutation);
await markMutationAsSynced(mutation.id);
} catch (e, stackTrace) {
// Log error but continue with next mutation
developer.log(
'Error syncing mutation ${mutation.id}: $e',
name: 'OfflineMutationQueue',
error: e,
stackTrace: stackTrace,
);
}
}
await removeSyncedMutations();
}
Future<void> close() async {
await _mutationsBox.close();
}
}
@@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class PexelsImage {
final int id;
final String url;
final String fullUrl;
final String? photographer;
final String? photographerUrl;
final int? width;
final int? height;
final String? alt;
PexelsImage({
required this.id,
required this.url,
required this.fullUrl,
this.photographer,
this.photographerUrl,
this.width,
this.height,
this.alt,
});
factory PexelsImage.fromJson(Map<String, dynamic> json) {
final src = json['src'] as Map<String, dynamic>;
return PexelsImage(
id: json['id'] as int,
url: src['large'] as String? ?? src['medium'] as String,
fullUrl: src['original'] as String? ?? src['large'] as String,
photographer: json['photographer'] as String?,
photographerUrl: json['photographer_url'] as String?,
width: json['width'] as int?,
height: json['height'] as int?,
alt: json['alt'] as String?,
);
}
}
class PexelsImageSearchService {
final String _apiKey;
final http.Client _client;
PexelsImageSearchService({
required String apiKey,
http.Client? client,
}) : _apiKey = apiKey,
_client = client ?? http.Client();
Future<List<PexelsImage>> searchImages({
required String query,
int perPage = 10,
String orientation = 'landscape',
}) async {
try {
final uri = Uri.https('api.pexels.com', '/v1/search', {
'query': query,
'per_page': perPage.toString(),
'orientation': orientation,
});
final response = await _client.get(
uri,
headers: {
'Authorization': _apiKey,
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final photos = data['photos'] as List;
return photos
.map((json) => PexelsImage.fromJson(json as Map<String, dynamic>))
.toList();
} else {
throw Exception('Failed to search images: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error searching images: $e');
}
}
Future<PexelsImage?> getRandomImage({
String? query,
String orientation = 'landscape',
}) async {
try {
final params = <String, String>{
'per_page': '1',
'orientation': orientation,
};
if (query != null) {
params['query'] = query;
}
final uri = Uri.https('api.pexels.com', '/v1/curated', params);
final response = await _client.get(
uri,
headers: {
'Authorization': _apiKey,
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final photos = data['photos'] as List;
if (photos.isNotEmpty) {
return PexelsImage.fromJson(photos[0] as Map<String, dynamic>);
}
return null;
} else {
throw Exception('Failed to get random image: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error getting random image: $e');
}
}
void dispose() {
_client.close();
}
}
@@ -0,0 +1,164 @@
import 'dart:io';
import 'package:record/record.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'mistral_ai_service.dart';
class VoiceRecordingException implements Exception {
final String message;
VoiceRecordingException(this.message);
@override
String toString() => 'VoiceRecordingException: $message';
}
class VoiceRecordingService {
final AudioRecorder _recorder = AudioRecorder();
final MistralAIService _mistralService;
String? _currentRecordingPath;
bool _isRecording = false;
VoiceRecordingService({required MistralAIService mistralService})
: _mistralService = mistralService;
bool get isRecording => _isRecording;
Future<bool> requestPermissions() async {
try {
final microphoneStatus = await Permission.microphone.request();
await Permission.storage.request();
return microphoneStatus == PermissionStatus.granted ||
microphoneStatus == PermissionStatus.limited;
} catch (e) {
throw VoiceRecordingException('Failed to request permissions: $e');
}
}
Future<void> startRecording() async {
try {
if (_isRecording) {
throw VoiceRecordingException('Recording is already in progress');
}
final hasPermission = await requestPermissions();
if (!hasPermission) {
throw VoiceRecordingException('Microphone permission denied');
}
final directory = await getTemporaryDirectory();
_currentRecordingPath = '${directory.path}/voice_recording_${DateTime.now().millisecondsSinceEpoch}.wav';
await _recorder.start(
const RecordConfig(
encoder: AudioEncoder.wav,
bitRate: 128000,
sampleRate: 44100,
),
path: _currentRecordingPath!,
);
_isRecording = true;
} catch (e) {
if (e is VoiceRecordingException) rethrow;
throw VoiceRecordingException('Failed to start recording: $e');
}
}
Future<String> stopRecording() async {
try {
if (!_isRecording) {
throw VoiceRecordingException('No recording in progress');
}
final path = await _recorder.stop();
_isRecording = false;
if (path == null) {
throw VoiceRecordingException('Failed to save recording');
}
_currentRecordingPath = path;
return path;
} catch (e) {
if (e is VoiceRecordingException) rethrow;
throw VoiceRecordingException('Failed to stop recording: $e');
}
}
Future<String> transcribeRecording({String? audioFilePath}) async {
try {
final filePath = audioFilePath ?? _currentRecordingPath;
if (filePath == null) {
throw VoiceRecordingException('No audio file available for transcription');
}
final file = File(filePath);
if (!await file.exists()) {
throw VoiceRecordingException('Audio file does not exist');
}
final transcription = await _mistralService.transcribeAudio(
audioFilePath: filePath,
);
// Clean up the temporary file
try {
await file.delete();
} catch (e) {
// Ignore cleanup errors
}
_currentRecordingPath = null;
return transcription;
} catch (e) {
if (e is VoiceRecordingException || e is MistralAIException) rethrow;
throw VoiceRecordingException('Failed to transcribe recording: $e');
}
}
Future<String> recordAndTranscribe() async {
try {
await startRecording();
// Note: The caller should handle the timing of when to stop recording
// This method is just a convenience wrapper
throw VoiceRecordingException(
'Use startRecording() and stopRecording() separately, then call transcribeRecording()',
);
} catch (e) {
if (e is VoiceRecordingException) rethrow;
throw VoiceRecordingException('Failed in record and transcribe flow: $e');
}
}
Future<void> cancelRecording() async {
try {
if (_isRecording) {
await _recorder.stop();
_isRecording = false;
// Clean up the file if it exists
if (_currentRecordingPath != null) {
final file = File(_currentRecordingPath!);
try {
await file.delete();
} catch (e) {
// Ignore cleanup errors
}
_currentRecordingPath = null;
}
}
} catch (e) {
throw VoiceRecordingException('Failed to cancel recording: $e');
}
}
void dispose() {
if (_isRecording) {
cancelRecording();
}
_recorder.dispose();
}
}