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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user