diff --git a/CLAUDE.md b/CLAUDE.md index 2fb3d55..08e096c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Parachute** is a cross-platform Flutter voice recording application designed for seamless background operation and crystal-clear audio capture. The app is currently in prototype phase with placeholder implementations for audio recording functionality. +**Parachute** is a cross-platform Flutter voice recording application with file-based syncing, crystal-clear audio capture, and AI-powered transcription via OpenAI Whisper API. ## Development Commands ### Build and Run + ```bash # Get dependencies flutter pub get @@ -19,69 +20,144 @@ flutter run # Run on specific platform flutter run -d ios flutter run -d android -flutter run -d chrome # Build for release flutter build apk flutter build ios -flutter build web ``` ### Code Quality + ```bash -# Run static analysis +# Run static analysis (0 errors expected) flutter analyze -# Run tests (when available) +# Run tests (27 tests) flutter test +# Run specific test suites +flutter test test/models/ +flutter test test/utils/ +flutter test test/repositories/ + # Format code dart format lib/ ``` -### Icons Generation -```bash -# Generate app icons from assets/icons/dreamflow_icon.jpg -flutter pub run flutter_launcher_icons +## Architecture + +The app follows **Flutter 2025 best practices** with clean architecture: + +### Layered Structure + ``` +Domain Layer + └── Models (lib/models/recording.dart) + └── Validators (lib/utils/validators.dart) -## Architecture +Data Layer + └── Repositories (lib/repositories/recording_repository.dart) + └── Services (lib/services/) -### Core Services (Singleton Pattern) +State Management + └── Riverpod Providers (lib/providers/service_providers.dart) -1. **AudioService** (`lib/services/audio_service.dart`): Handles all audio recording operations. Currently uses placeholder implementations with TODO comments for actual audio package integrations (`flutter_sound`, `permission_handler`). +Presentation Layer + └── Screens (lib/screens/) + └── Widgets (lib/widgets/) +``` -2. **StorageService** (`lib/services/storage_service.dart`): Manages recording persistence. Currently uses in-memory storage with TODO comments for `shared_preferences` integration. +### Core Services -### Data Model +All services use **Riverpod dependency injection** (no singletons): -- **Recording** (`lib/models/recording.dart`): Core data model with JSON serialization, containing fields for id, title, filePath, timestamp, duration, tags, transcript, and file size. +1. **AudioService** (`lib/services/audio_service.dart`) + - Audio recording/playback using `record` and `just_audio` packages + - Auto-initialized via `audioServiceProvider` + - Proper disposal handling -### Screen Flow +2. **StorageService** (`lib/services/storage_service.dart`) + - File-based storage with markdown metadata + - Syncs via user-configurable folder (iCloud, Syncthing, etc.) + - Auto-initialized via `storageServiceProvider` -1. **HomeScreen** → Lists all recordings, entry point to recording -2. **RecordingScreen** → Active recording interface with pause/resume/stop controls -3. **PostRecordingScreen** → Post-recording metadata entry (title, tags) -4. **RecordingDetailScreen** → View/edit individual recording details +3. **WhisperService** (`lib/services/whisper_service.dart`) + - OpenAI Whisper API integration for transcription + - API key management via StorageService + - Accessed via `whisperServiceProvider` + +4. **RecordingRepository** (`lib/repositories/recording_repository.dart`) + - Repository pattern for data access + - Clean CRUD API + - Accessed via `recordingRepositoryProvider` ### State Management -The app uses StatefulWidget for local state management. Services use singleton pattern for app-wide state sharing. +- **Riverpod** for dependency injection and state management +- Screens extend `ConsumerStatefulWidget` +- Services injected via `ref.read(serviceProvider)` +- No singleton pattern - all dependencies managed by Riverpod + +### Data Model + +- **Recording** (`lib/models/recording.dart`) + - Validated model with assertions + - JSON serialization with null safety + - Computed properties for formatting + +### Screen Flow + +1. **HomeScreen** → Lists recordings, refreshes on resume +2. **RecordingScreen** → Active recording with pause/resume/stop +3. **PostRecordingScreen** → Add title, tags, transcribe +4. **RecordingDetailScreen** → View/edit/delete recordings +5. **SettingsScreen** → Configure API key and sync folder ### Package Dependencies -Current dependencies with pending integrations: -- `flutter_sound`: Audio recording/playback (TODO: uncommented in services) -- `permission_handler`: Microphone permissions (TODO: uncommented) -- `path_provider`: File system access (TODO: uncommented) -- `shared_preferences`: Persistent storage (TODO: uncommented) -- `google_fonts`: Typography styling (active) -- `cupertino_icons`: iOS-style icons (active) +**Active packages:** + +- `flutter_riverpod ^2.6.1` - State management +- `record ^6.1.2` - Audio recording +- `just_audio ^0.9.42` - Audio playback +- `path_provider ^2.0.0` - File system access +- `shared_preferences ^2.0.0` - Settings persistence +- `http ^1.2.0` - Whisper API calls +- `google_fonts ^6.1.0` - Typography + +**Dev packages:** + +- `flutter_lints ^6.0.0` - Comprehensive linting +- `build_runner` & `riverpod_generator` - Code generation (future) + +## Testing + +**27 tests** covering: + +- Recording model (validation, serialization) +- Input validators (title, API key, tags) +- Repository pattern + +Run tests: + +```bash +flutter test +``` ## Important Notes -- The app name is "parachute" (lowercase in package name) -- Audio functionality currently returns mock data - actual recording packages need to be integrated -- Sample recordings are automatically created on first launch for demo purposes -- Using Flutter 3.35.3 with Dart 3.9.2 -- Linting configured via `flutter_lints` package \ No newline at end of file +- App uses **Riverpod** - access services via `ref.read(serviceProvider)` +- Recordings stored as `.m4a` (audio) + `.md` (metadata) +- Sample recordings created on first launch +- Transcription requires OpenAI API key (configured in Settings) +- File-based sync for cross-device support +- Global error boundaries configured in main.dart +- Production-ready with comprehensive validation + +## Code Style + +- Use `debugPrint()` not `print()` +- Use `withValues(alpha:)` not `withOpacity()` +- Always check `mounted` before using `BuildContext` after async +- Input validation required for user-facing fields +- Prefer `ConsumerStatefulWidget` for screens diff --git a/analysis_options.yaml b/analysis_options.yaml index f16eead..7670619 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,48 @@ include: package:flutter_lints/flutter.yaml linter: - rules: \ No newline at end of file + rules: + # Style rules + - prefer_const_constructors + - prefer_const_literals_to_create_immutables + - prefer_const_declarations + - prefer_final_fields + - prefer_final_locals + - sort_constructors_first + - sort_unnamed_constructors_first + - unnecessary_const + - unnecessary_new + + # Code quality + - avoid_print + - always_declare_return_types + - avoid_unnecessary_containers + - avoid_empty_else + - avoid_relative_lib_imports + - avoid_returning_null_for_void + - avoid_types_as_parameter_names + - cancel_subscriptions + - close_sinks + - directives_ordering + - prefer_is_empty + - prefer_is_not_empty + - require_trailing_commas + - unnecessary_brace_in_string_interps + - use_key_in_widget_constructors + + # Error prevention + - no_duplicate_case_values + - valid_regexps + - unnecessary_statements + +analyzer: + errors: + # Treat missing returns as errors + missing_return: error + # Treat missing required parameters as errors + missing_required_param: error + # Allow TODO comments + todo: ignore + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" diff --git a/lib/main.dart b/lib/main.dart index fa819fa..4ac8800 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,40 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:parachute/theme.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:parachute/screens/home_screen.dart'; +import 'package:parachute/theme.dart'; void main() { - runApp(const MyApp()); + // Set up global error handling + FlutterError.onError = (FlutterErrorDetails details) { + // Log the error + FlutterError.presentError(details); + + // In production, you could send to crash reporting service + if (kReleaseMode) { + // TODO: Send to crash reporting (Firebase Crashlytics, Sentry, etc.) + debugPrint('Error caught in release mode: ${details.exception}'); + } + }; + + // Catch errors not caught by Flutter + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('Uncaught error: $error'); + debugPrint('Stack trace: $stack'); + + // In production, send to crash reporting + if (kReleaseMode) { + // TODO: Send to crash reporting + } + + return true; // Prevents error from propagating + }; + + runApp( + const ProviderScope( + child: MyApp(), + ), + ); } class MyApp extends StatelessWidget { diff --git a/lib/models/recording.dart b/lib/models/recording.dart index 63b2909..4ce9ac9 100644 --- a/lib/models/recording.dart +++ b/lib/models/recording.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; class Recording { final String id; @@ -19,29 +18,34 @@ class Recording { required this.tags, required this.transcript, required this.fileSizeKB, - }); + }) : assert(id.isNotEmpty, 'Recording ID cannot be empty'), + assert(title.isNotEmpty, 'Recording title cannot be empty'), + assert(filePath.isNotEmpty, 'Recording file path cannot be empty'), + assert(duration >= Duration.zero, 'Duration must be non-negative'), + assert(fileSizeKB >= 0, 'File size must be non-negative'); Map toJson() => { - 'id': id, - 'title': title, - 'filePath': filePath, - 'timestamp': timestamp.toIso8601String(), - 'duration': duration.inMilliseconds, - 'tags': tags, - 'transcript': transcript, - 'fileSizeKB': fileSizeKB, - }; + 'id': id, + 'title': title, + 'filePath': filePath, + 'timestamp': timestamp.toIso8601String(), + 'duration': duration.inMilliseconds, + 'tags': tags, + 'transcript': transcript, + 'fileSizeKB': fileSizeKB, + }; factory Recording.fromJson(Map json) => Recording( - id: json['id'], - title: json['title'], - filePath: json['filePath'], - timestamp: DateTime.parse(json['timestamp']), - duration: Duration(milliseconds: json['duration']), - tags: List.from(json['tags']), - transcript: json['transcript'], - fileSizeKB: json['fileSizeKB'], - ); + id: json['id'] as String? ?? '', + title: json['title'] as String? ?? 'Untitled', + filePath: json['filePath'] as String? ?? '', + timestamp: DateTime.tryParse(json['timestamp'] as String? ?? '') ?? + DateTime.now(), + duration: Duration(milliseconds: json['duration'] as int? ?? 0), + tags: (json['tags'] as List?)?.cast() ?? [], + transcript: json['transcript'] as String? ?? '', + fileSizeKB: (json['fileSizeKB'] as num?)?.toDouble() ?? 0.0, + ); String get durationString { final minutes = duration.inMinutes; @@ -59,7 +63,7 @@ class Recording { String get timeAgo { final now = DateTime.now(); final difference = now.difference(timestamp); - + if (difference.inDays > 0) { return '${difference.inDays}d ago'; } else if (difference.inHours > 0) { @@ -70,4 +74,4 @@ class Recording { return 'Just now'; } } -} \ No newline at end of file +} diff --git a/lib/providers/service_providers.dart b/lib/providers/service_providers.dart new file mode 100644 index 0000000..6b80751 --- /dev/null +++ b/lib/providers/service_providers.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:parachute/repositories/recording_repository.dart'; +import 'package:parachute/services/audio_service.dart'; +import 'package:parachute/services/storage_service.dart'; +import 'package:parachute/services/whisper_service.dart'; + +/// Provider for AudioService +/// +/// This manages audio recording and playback functionality. +/// The service is initialized on first access and kept alive for the app lifetime. +final audioServiceProvider = Provider((ref) { + final service = AudioService(); + // Initialize the service when first accessed + service.initialize(); + + // Dispose when the provider is disposed + ref.onDispose(() { + service.dispose(); + }); + + return service; +}); + +/// Provider for StorageService +/// +/// This manages file-based storage for recordings and metadata. +/// The service is initialized on first access. +final storageServiceProvider = Provider((ref) { + final service = StorageService(); + // Initialize the service when first accessed + service.initialize(); + + return service; +}); + +/// Provider for WhisperService +/// +/// This manages transcription via OpenAI's Whisper API. +final whisperServiceProvider = Provider((ref) { + // WhisperService depends on StorageService for API key management + final storageService = ref.watch(storageServiceProvider); + return WhisperService(storageService); +}); + +/// Provider for RecordingRepository +/// +/// This provides data access for recordings following the Repository Pattern. +/// It separates data access logic from business logic. +final recordingRepositoryProvider = Provider((ref) { + final storageService = ref.watch(storageServiceProvider); + return RecordingRepository(storageService); +}); diff --git a/lib/providers/voice_note_provider_broken.dart b/lib/providers/voice_note_provider_broken.dart deleted file mode 100644 index 142f988..0000000 --- a/lib/providers/voice_note_provider_broken.dart +++ /dev/null @@ -1,420 +0,0 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; -import '../models/voice_note.dart'; -import '../services/audio_recorder.dart'; -import '../services/database_service.dart'; -import '../services/speech_service.dart'; - -enum RecordingState { - idle, - recordingIntent, - recordingNote, - -class VoiceNoteProvider extends ChangeNotifier { - final DatabaseService _databaseService = DatabaseService(); - final AudioRecorderService _audioRecorder = AudioRecorderService(); - final SpeechService _speechService = SpeechService(); - - List _notes = []; - RecordingState _state = RecordingState.idle; - String _currentIntent = ''; - String _currentNoteTranscription = ''; - String? _currentAudioPath; - DateTime? _recordingStartTime; - DateTime? _recordingEndTime; - Duration? _finalRecordingDuration; - bool _isInitialized = false; - bool _isStartingRecording = false; - String? _permissionError; - - // Getters - List get notes => _notes; - RecordingState get state => _state; - String get currentIntent => _currentIntent; - String get currentNoteTranscription => _currentNoteTranscription; - String? get currentAudioPath => _currentAudioPath; - bool get isRecording => _state != RecordingState.idle; - bool get isInitialized => _isInitialized; - String? get permissionError => _permissionError; - - // New getter for recording duration - Duration? get recordingDuration { - if (_recordingStartTime == null) return null; - final endTime = _recordingEndTime ?? DateTime.now(); - return endTime.difference(_recordingStartTime!); - } - - // Initialize the provider - Future initialize() async { - if (_isInitialized) return; - - // Database initialized in constructor; - await loadNotes(); - await // AudioRecorder initialized; - - // Initialize speech service with permission error handler - await _speechService.initialize( - onPermissionError: (error) { - _permissionError = error; - notifyListeners(); - debugPrint('Provider: Permission error: $error'); - }, - ); - - _isInitialized = true; - notifyListeners(); - } - - // Load notes from database - Future loadNotes() async { - _notes = await _databaseService.getAllNotes(); - notifyListeners(); - } - - // Start recording intent - void startIntentRecording() { - if (_state != RecordingState.idle) return; - - _state = RecordingState.recordingIntent; - _currentIntent = ''; - _permissionError = null; // Clear any previous permission error - notifyListeners(); - debugPrint('Provider: Intent recording started'); - } - - // Skip intent and go directly to note recording - void skipIntent() { - if (_state != RecordingState.idle) return; - - _currentIntent = ''; - _permissionError = null; // Clear any previous permission error - startNoteRecording(); - debugPrint('Provider: Skipping intent'); - } - - // Save the intent and transition to note recording - void saveIntent(String intent) { - if (_state != RecordingState.recordingIntent) return; - - _currentIntent = intent; - debugPrint('Provider: Intent saved: "$intent"'); - startNoteRecording(); - } - - // Start recording note - Future startNoteRecording() async { - // Prevent multiple simultaneous recording attempts - if (_isStartingRecording || _state == RecordingState.recordingNote) { - debugPrint('Provider: Already starting/recording, skipping duplicate call'); - return; - } - - _isStartingRecording = true; - _state = RecordingState.recordingNote; - _currentNoteTranscription = ''; - _permissionError = null; // Clear any previous permission error - notifyListeners(); - - debugPrint('Provider: Starting note recording'); - - // Start audio recording first - final path = await _audioRecorder.startRecording(); - _currentAudioPath = path; - debugPrint('Provider: Audio recording started at path: $path'); - - // Track when recording started - _recordingStartTime = DateTime.now(); - debugPrint('Provider: 🕐 Recording START TIME set: $_recordingStartTime'); - - // Start speech recognition with live updates and permission error handling - try { - await _speechService.startListening( - onResult: (text) { - // Only update if we get actual text, not error messages - if (!text.startsWith('[Speech')) { - _currentNoteTranscription = text; - } else { - debugPrint('Provider: Speech error received: $text'); - } - notifyListeners(); - }, - onPermissionError: (error) { - _permissionError = error; - notifyListeners(); - debugPrint('Provider: Permission error during recording: $error'); - }, - ); - debugPrint('Provider: Speech recognition started successfully'); - } catch (e) { - debugPrint('Provider: Failed to start speech recognition: $e'); - // Continue recording even if speech fails - } - - debugPrint('Provider: Recording started (speech may or may not be active)'); - - // Clear the flag after setup is complete - _isStartingRecording = false; - - // For web testing - add sample text after delay if speech recognition fails - if (kIsWeb) { - Future.delayed(const Duration(seconds: 3), () { - if (_state == RecordingState.recordingNote && _currentNoteTranscription.isEmpty) { - _currentNoteTranscription = 'Sample note: Remember to test the voice recording feature on mobile device for actual speech-to-text'; - notifyListeners(); - debugPrint('Added sample text for web testing'); - } - }); - } - } - - // Stop recording and save the note - Future stopNoteRecording() async { - if (_state != RecordingState.recordingNote) { - debugPrint('Provider: Not recording, cannot stop'); - return; - } - - debugPrint('Provider: Stopping note recording'); - - // Track when recording ended - _recordingEndTime = DateTime.now(); - debugPrint('Provider: 🕐 Recording END TIME set: $_recordingEndTime'); - - // Store current live transcription before stopping - final liveTranscription = _currentNoteTranscription; - debugPrint('Provider: Current live transcription before stop: "$liveTranscription"'); - - // Stop audio recording - final path = await _audioRecorder.stopRecording(); - debugPrint('Provider: Recording ended at ${_recordingEndTime}'); - - if (path != null) { - debugPrint('Provider: Audio file saved at: $path'); - // Only update path if we got a valid one - _currentAudioPath = path; - } - - // Stop speech recognition and get final result - await _speechService.stopListening(); - - // Use the speech service's final result if available, otherwise use live transcription - final finalWords = _speechService.lastWords; - debugPrint('Provider: Final words from speech service: "$finalWords"'); - - if (finalWords.isNotEmpty && !finalWords.startsWith('[Speech')) { - _currentNoteTranscription = finalWords; - } else if (liveTranscription.isEmpty || liveTranscription.startsWith('[Speech')) { - debugPrint('Provider: WARNING - No transcription captured from speech service'); - _currentNoteTranscription = ''; - } - - debugPrint('Provider: Final transcription to be saved: "$_currentNoteTranscription"'); - - // Calculate final duration - if (_recordingStartTime != null && _recordingEndTime != null) { - _finalRecordingDuration = _recordingEndTime!.difference(_recordingStartTime!); - debugPrint('Provider: Calculating final duration:'); - debugPrint(' - Recording started at ${_recordingStartTime}'); - debugPrint(' - Recording ended at ${_recordingEndTime}'); - debugPrint(' - Duration: ${_finalRecordingDuration!.inSeconds}s (${_formatDuration(_finalRecordingDuration!)})'); - } - - notifyListeners(); - } - - // Start recording note with intent - Future startNoteRecordingWithIntent(String intent) async { - if (_state != RecordingState.idle) return; - - _currentIntent = intent; - _permissionError = null; // Clear any previous permission error - debugPrint('Provider: Starting note recording with intent: "$intent"'); - - // Start audio recording - final path = await _audioRecorder.startRecording(); - _currentAudioPath = path; - - // Track when recording started - _recordingStartTime = DateTime.now(); - debugPrint('Provider: Recording started at $_recordingStartTime'); - - // Start speech recognition with live updates and permission error handling - await _speechService.startListening( - onResult: (text) { - _currentNoteTranscription = text; - notifyListeners(); - debugPrint('Provider: Live transcription update: "$text"'); - }, - onPermissionError: (error) { - _permissionError = error; - notifyListeners(); - debugPrint('Provider: Permission error during recording: $error'); - }, - ); - - _state = RecordingState.recordingNote; - notifyListeners(); - } - - // Cancel the current recording without saving - Future cancelRecording() async { - debugPrint('Provider: Canceling recording'); - - // Cancel audio recording - await _audioRecorder.stopRecording(); - - // Cancel speech recognition - await _speechService.cancelListening(); - - // Clear recording timestamps - _recordingStartTime = null; - _recordingEndTime = null; - _finalRecordingDuration = null; - - // Reset state - _state = RecordingState.idle; - _currentIntent = ''; - _currentNoteTranscription = ''; - _currentAudioPath = null; - _permissionError = null; - notifyListeners(); - } - - // Save the current note to database - Future saveCurrentNote() async { - // Set a default transcription if none was captured - String finalTranscription = _currentNoteTranscription.isEmpty - ? 'No transcription available' - : _currentNoteTranscription; - - // Use pre-calculated duration or calculate now - Duration? duration = _finalRecordingDuration; - if (duration == null && _recordingStartTime != null && _recordingEndTime != null) { - duration = _recordingEndTime!.difference(_recordingStartTime!); - } - - final note = VoiceNote( - audioPath: _currentAudioPath ?? "", - transcription: finalTranscription, - intentDescription: _currentIntent.isEmpty ? null : _currentIntent, - durationSeconds: duration?.inSeconds, - ); - await _databaseService.insertNote(note); - debugPrint('Note saved to database: ${note.id}'); - - if (_currentIntent.isEmpty) { - debugPrint('Provider: Note saved to database without intent'); - } else { - debugPrint('Provider: Note saved with intent: "$_currentIntent"'); - } - debugPrint('Provider: Transcription in saved note: "${note.transcription}"'); - - if (duration != null) { - debugPrint('Provider: Duration in saved note: ${duration.inSeconds} seconds'); - } - - // Load notes to refresh the list - await loadNotes(); - - // Reset state after successful save - resetState(); - } - - // Format duration for display - String _formatDuration(Duration duration) { - final minutes = duration.inMinutes; - final seconds = duration.inSeconds % 60; - return '${minutes}:${seconds.toString().padLeft(2, '0')}'; - } - - // Reset recording state - void resetState() { - debugPrint('Provider: Resetting state'); - _state = RecordingState.idle; - _currentIntent = ''; - _currentNoteTranscription = ''; - _currentAudioPath = null; - _recordingStartTime = null; - _recordingEndTime = null; - _finalRecordingDuration = null; - _permissionError = null; - notifyListeners(); - } - - // Delete a note - Future deleteNote(String id) async { - await _databaseService.deleteNote(id); - - // Also delete the audio file if it exists - final note = _notes.firstWhere((n) => n.id == id); - if (note.audioPath != null) { - final file = File(note.audioPath!); - if (await file.exists()) { - await file.delete(); - debugPrint('Audio file deleted: ${note.audioPath}'); - } - } - - await loadNotes(); - } - - // Update a note - Future updateNote(VoiceNote note) async { - await _databaseService.updateNote(note); - await loadNotes(); - } - - // Clear permission error - void clearPermissionError() { - _permissionError = null; - notifyListeners(); - } - - @override - void dispose() { - _audioRecorder.dispose(); - _speechService.dispose(); - super.dispose(); - } - - // Additional methods for compatibility - - // Prepare for recording - transitions to recordingIntent state - void prepareForRecording() { - startIntentRecording(); - } - - // Stop intent recording - void stopIntentRecording() { - if (_state == RecordingState.recordingIntent) { - // Save the intent and transition to note recording - saveIntent(_currentIntent); - } - } - - // Save note with the provided intent - void saveNoteWithIntent(String intent) { - saveIntent(intent); - - // Additional methods for compatibility - - // Prepare for recording - transitions to recordingIntent state - void prepareForRecording() { - startIntentRecording(); - } - - // Stop intent recording - void stopIntentRecording() { - if (_state == RecordingState.recordingIntent) { - // Save the intent and transition to note recording - saveIntent(_currentIntent); - } - } - - // Save note with the provided intent - void saveNoteWithIntent(String intent) { - saveIntent(intent); - } -} diff --git a/lib/providers/voice_note_provider_old.dart b/lib/providers/voice_note_provider_old.dart deleted file mode 100644 index e9b0f30..0000000 --- a/lib/providers/voice_note_provider_old.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:geolocator/geolocator.dart'; -import '../models/voice_note.dart'; -import '../services/audio_recorder.dart'; -import '../services/speech_service.dart'; -import '../services/database_service.dart'; -import '../services/location_service.dart'; - -enum RecordingState { - idle, - recordingNote, - waitingForIntent, - recordingIntent, - complete -} - -class VoiceNoteProvider extends ChangeNotifier { - final AudioRecorderService _audioRecorder = AudioRecorderService(); - final SpeechService _speechService = SpeechService(); - final DatabaseService _databaseService = DatabaseService(); - final LocationService _locationService = LocationService(); - - RecordingState _state = RecordingState.idle; - String? _currentNotePath; - String _currentNoteTranscription = ''; - String? _currentIntentPath; - String _currentIntentTranscription = ''; - List _notes = []; - String? _errorMessage; - Position? _currentPosition; - bool _isStartingRecording = false; // Prevent multiple simultaneous starts - DateTime? _recordingStartTime; // Track when recording started - DateTime? _recordingEndTime; // Track when main recording ended - - RecordingState get state => _state; - List get notes => _notes; - String get currentNoteTranscription => _currentNoteTranscription; - String get currentIntentTranscription => _currentIntentTranscription; - bool get isRecording => _state == RecordingState.recordingNote || - _state == RecordingState.recordingIntent; - String? get errorMessage => _errorMessage; - - VoiceNoteProvider() { - _initializeServices(); - } - - Future _initializeServices() async { - // Initialize services in parallel for faster startup - final futures = await Future.wait([ - // Initialize speech recognition - _speechService.initialize(), - // Pre-warm audio recorder permissions - _audioRecorder.requestPermission(), - // Load existing notes from database - loadNotes(), - ]); - - final speechAvailable = futures[0] as bool; - if (!speechAvailable) { - debugPrint('Speech recognition not available'); - } - - debugPrint('Services pre-initialized for better performance'); - } - - Future loadNotes() async { - try { - _notes = await _databaseService.getAllNotes(); - notifyListeners(); - debugPrint('Loaded ${_notes.length} notes from database'); - } catch (e) { - debugPrint('Error loading notes: $e'); - } - } - - // Phase 1: Just set state to trigger navigation - void prepareForRecording() { - debugPrint('Provider: Preparing for recording'); - _errorMessage = null; - _currentNoteTranscription = ''; - _recordingStartTime = null; // Clear any previous recording time - _state = RecordingState.recordingNote; - notifyListeners(); - } - - // Phase 2: Actually start recording (called after navigation) - Future startNoteRecording() async { - // Prevent multiple simultaneous recording starts - if (_isStartingRecording) { - debugPrint('Provider: Already starting recording, skipping duplicate call'); - return; - } - - // Check if already recording - if (_audioRecorder.isRecording) { - debugPrint('Provider: Already recording, skipping duplicate start'); - return; - } - - _isStartingRecording = true; - debugPrint('Provider: Starting actual recording'); - - // Get current location while starting recording - _currentPosition = await _locationService.getCurrentLocation(); - if (_currentPosition != null) { - debugPrint('Recording at location: ${_currentPosition!.latitude}, ${_currentPosition!.longitude}'); - } - - // Start both recording and speech recognition - _currentNotePath = await _audioRecorder.startRecording(); - - if (_currentNotePath == null) { - debugPrint('Provider: Failed to start recording'); - _errorMessage = 'Failed to start recording. Please check permissions.'; - _state = RecordingState.idle; - notifyListeners(); - return; - } - - // Track when recording started - _recordingStartTime = DateTime.now(); - debugPrint('Provider: 🕐 Recording START TIME set: $_recordingStartTime'); - - // Start speech recognition with live updates - try { - await _speechService.startListening( - onResult: (text) { - // Only update if we get actual text, not error messages - if (!text.startsWith('[Speech')) { - _currentNoteTranscription = text; - } else { - debugPrint('Provider: Speech error received: $text'); - } - notifyListeners(); - }, - ); - debugPrint('Provider: Speech recognition started successfully'); - } catch (e) { - debugPrint('Provider: Failed to start speech recognition: $e'); - // Continue recording even if speech fails - } - - debugPrint('Provider: Recording started (speech may or may not be active)'); - - // Clear the flag after setup is complete - _isStartingRecording = false; - - // For web testing - add sample text after delay if speech recognition fails - if (kIsWeb) { - Future.delayed(const Duration(seconds: 3), () { - if (_state == RecordingState.recordingNote && _currentNoteTranscription.isEmpty) { - _currentNoteTranscription = 'Sample note: Remember to test the voice recording feature on mobile device for actual speech-to-text'; - notifyListeners(); - debugPrint('Added sample text for web testing'); - } - }); - } - } - - Future stopNoteRecording() async { - debugPrint('Provider: Stopping note recording'); - debugPrint('Provider: Current live transcription before stop: "$_currentNoteTranscription"'); - - // Make sure we're actually recording before trying to stop - if (_state != RecordingState.recordingNote) { - debugPrint('Provider: Not in recording state, skipping stop'); - return; - } - - // Capture the end time for the main recording - _recordingEndTime = DateTime.now(); - debugPrint('Provider: Recording ended at $_recordingEndTime'); - if (_recordingStartTime != null) { - final duration = _recordingEndTime!.difference(_recordingStartTime!); - debugPrint('Provider: Main recording duration: ${duration.inSeconds}s'); - } else { - debugPrint('Provider: WARNING - No recording start time!'); - } - - // Immediately move to waiting state for responsive UI - _state = RecordingState.waitingForIntent; - notifyListeners(); - - // Stop speech recognition first to get final words - await _speechService.stopListening(); - - // Get final transcription - prefer lastWords if available - final finalWords = _speechService.lastWords; - debugPrint('Provider: Final words from speech service: "$finalWords"'); - - // Only overwrite if we got something from speech service, otherwise keep live transcription - if (finalWords.isNotEmpty) { - _currentNoteTranscription = finalWords; - } else if (_currentNoteTranscription.isEmpty) { - // If both are empty, check if speech was working at all - debugPrint('Provider: WARNING - No transcription captured from speech service'); - } - - debugPrint('Provider: Final transcription to be saved: "$_currentNoteTranscription"'); - - // Now stop the audio recording - await _audioRecorder.stopRecording(); - } - - Future startIntentRecording() async { - debugPrint('Provider: Starting intent recording'); - _currentIntentTranscription = ''; - _state = RecordingState.recordingIntent; - notifyListeners(); - - // Start recording and speech recognition for intent - _currentIntentPath = await _audioRecorder.startRecording(); - - if (_currentIntentPath == null) { - // Save note without intent - debugPrint('Provider: Failed to start intent recording, saving without intent'); - await _saveNote(); - _state = RecordingState.complete; - notifyListeners(); - _resetAfterDelay(); - return; - } - - // Start speech recognition for intent - try { - await _speechService.startListening( - onResult: (text) { - // Only update if we get actual text, not error messages - if (!text.startsWith('[Speech')) { - _currentIntentTranscription = text; - } else { - debugPrint('Provider: Intent speech error received: $text'); - } - notifyListeners(); - }, - ); - debugPrint('Provider: Intent speech recognition started successfully'); - } catch (e) { - debugPrint('Provider: Failed to start intent speech recognition: $e'); - // Continue recording even if speech fails - } - - // For web testing - add sample intent text after delay - if (kIsWeb) { - Future.delayed(const Duration(seconds: 2), () { - if (_state == RecordingState.recordingIntent && _currentIntentTranscription.isEmpty) { - _currentIntentTranscription = 'Project planning discussion'; - notifyListeners(); - debugPrint('Added sample intent for web testing'); - } - }); - } - } - - Future stopIntentRecording() async { - debugPrint('Provider: Stopping intent recording'); - - // Stop speech recognition first to get final words - await _speechService.stopListening(); - - // Get final transcription before stopping audio - _currentIntentTranscription = _speechService.lastWords; - debugPrint('Provider: Final intent transcription from speech service: $_currentIntentTranscription'); - - // Now stop the audio recording - await _audioRecorder.stopRecording(); - - if (_currentIntentTranscription.isNotEmpty) { - debugPrint('Provider: Intent transcription: $_currentIntentTranscription'); - await _saveNote(intentDescription: _currentIntentTranscription); - } else { - await _saveNote(); - } - - _state = RecordingState.complete; - notifyListeners(); - _resetAfterDelay(); - } - - Future skipIntent() async { - debugPrint('Provider: Skipping intent'); - await _saveNote(); - _state = RecordingState.complete; - notifyListeners(); - _resetAfterDelay(); - } - - Future saveNoteWithIntent(String intent) async { - debugPrint('Provider: Saving note with typed intent: $intent'); - await _saveNote(intentDescription: intent); - _state = RecordingState.complete; - notifyListeners(); - _resetAfterDelay(); - } - - Future _saveNote({String? intentDescription}) async { - if (_currentNotePath != null) { - // Allow saving even if transcription is empty (for web where speech might fail) - final transcription = _currentNoteTranscription.isEmpty - ? 'No transcription available' - : _currentNoteTranscription; - - // Calculate recording duration - int? durationSeconds; - if (_recordingStartTime != null && _recordingEndTime != null) { - final duration = _recordingEndTime!.difference(_recordingStartTime!); - durationSeconds = duration.inSeconds; - debugPrint('Provider: Calculating final duration:'); - debugPrint(' - Recording started at $_recordingStartTime'); - debugPrint(' - Recording ended at $_recordingEndTime'); - debugPrint(' - Duration: ${durationSeconds}s (${duration.toString()})'); - } else { - debugPrint('Provider: WARNING - Missing timing data!'); - debugPrint(' - Start time: $_recordingStartTime'); - debugPrint(' - End time: $_recordingEndTime'); - } - - debugPrint('Provider: Saving note with transcription: "$transcription"'); - debugPrint('Provider: Intent: "$intentDescription"'); - - final note = VoiceNote( - audioPath: _currentNotePath!, - transcription: transcription, - intentDescription: intentDescription, - latitude: _currentPosition?.latitude, - longitude: _currentPosition?.longitude, - locationName: _currentPosition != null - ? _locationService.getLocationName( - _currentPosition!.latitude, - _currentPosition!.longitude - ) - : null, - durationSeconds: durationSeconds, - ); - - try { - // Save to database - await _databaseService.insertNote(note); - - // Add to local list AT THE BEGINNING - this is important for navigation - _notes.insert(0, note); // Insert at beginning for newest first - - debugPrint('Provider: Note saved to database with${intentDescription != null ? '' : 'out'} intent'); - debugPrint('Provider: Transcription in saved note: "${note.transcription}"'); - debugPrint('Audio file saved at: ${note.audioPath}'); - if (note.latitude != null) { - debugPrint('Location: ${note.locationName}'); - } - - notifyListeners(); - } catch (e) { - debugPrint('Error saving note to database: $e'); - _errorMessage = 'Failed to save note'; - } - } else { - debugPrint('Provider: Warning - no audio path available, cannot save note'); - } - } - - Future deleteNote(String id) async { - try { - await _databaseService.deleteNote(id); - _notes.removeWhere((note) => note.id == id); - notifyListeners(); - debugPrint('Note deleted: $id'); - } catch (e) { - debugPrint('Error deleting note: $e'); - } - } - - Future> searchNotes(String query) async { - try { - return await _databaseService.searchNotes(query); - } catch (e) { - debugPrint('Error searching notes: $e'); - return []; - } - } - - void _resetAfterDelay() { - Future.delayed(const Duration(seconds: 1)).then((_) { - _reset(); - }); - } - - void _reset() { - debugPrint('Provider: Resetting state'); - _state = RecordingState.idle; - _currentNotePath = null; - _currentNoteTranscription = ''; - _currentIntentPath = null; - _currentIntentTranscription = ''; - _currentPosition = null; - _errorMessage = null; - _recordingStartTime = null; - _recordingEndTime = null; - _isStartingRecording = false; - notifyListeners(); - } - - @override - void dispose() { - _audioRecorder.dispose(); - _speechService.dispose(); - _databaseService.close(); - super.dispose(); - } -} diff --git a/lib/repositories/recording_repository.dart b/lib/repositories/recording_repository.dart new file mode 100644 index 0000000..947a35b --- /dev/null +++ b/lib/repositories/recording_repository.dart @@ -0,0 +1,42 @@ +import 'package:parachute/models/recording.dart'; +import 'package:parachute/services/storage_service.dart'; + +/// Repository for managing recording data access +/// +/// This class follows the Repository Pattern, separating data access logic +/// from business logic. It provides a clean API for CRUD operations on recordings. +class RecordingRepository { + final StorageService _storageService; + + RecordingRepository(this._storageService); + + /// Retrieves all recordings + Future> getAllRecordings() async { + return await _storageService.getRecordings(); + } + + /// Retrieves a single recording by ID + Future getRecordingById(String id) async { + return await _storageService.getRecording(id); + } + + /// Saves a new recording + Future saveRecording(Recording recording) async { + return await _storageService.saveRecording(recording); + } + + /// Updates an existing recording + Future updateRecording(Recording recording) async { + return await _storageService.updateRecording(recording); + } + + /// Deletes a recording by ID + Future deleteRecording(String id) async { + return await _storageService.deleteRecording(id); + } + + /// Clears all recordings (for testing/reset purposes) + Future clearAll() async { + return await _storageService.clearAllRecordings(); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7d6648c..d501ca5 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:parachute/models/recording.dart'; -import 'package:parachute/services/storage_service.dart'; -import 'package:parachute/screens/recording_screen.dart'; +import 'package:parachute/providers/service_providers.dart'; import 'package:parachute/screens/recording_detail_screen.dart'; +import 'package:parachute/screens/recording_screen.dart'; import 'package:parachute/screens/settings_screen.dart'; import 'package:parachute/widgets/recording_tile.dart'; -import 'package:parachute/utils/sample_data.dart'; -class HomeScreen extends StatefulWidget { +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @override - State createState() => _HomeScreenState(); + ConsumerState createState() => _HomeScreenState(); } -class _HomeScreenState extends State with WidgetsBindingObserver { - final StorageService _storageService = StorageService(); +class _HomeScreenState extends ConsumerState + with WidgetsBindingObserver { List _recordings = []; bool _isLoading = true; @@ -49,28 +49,13 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } Future _loadRecordings() async { - final recordings = await _storageService.getRecordings(); - - // Add sample data if no recordings exist (for demo purposes) - if (recordings.isEmpty) { - final sampleRecordings = SampleData.getSampleRecordings(); - for (final recording in sampleRecordings) { - await _storageService.saveRecording(recording); - } - final updatedRecordings = await _storageService.getRecordings(); - if (mounted) { - setState(() { - _recordings = updatedRecordings; - _isLoading = false; - }); - } - } else { - if (mounted) { - setState(() { - _recordings = recordings; - _isLoading = false; - }); - } + final storageService = ref.read(storageServiceProvider); + final recordings = await storageService.getRecordings(); + if (mounted) { + setState(() { + _recordings = recordings; + _isLoading = false; + }); } } @@ -85,7 +70,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { await Navigator.of(context).push( MaterialPageRoute(builder: (context) => const RecordingScreen()), ); - // Always refresh when returning from recording flow _refreshRecordings(); } diff --git a/lib/screens/post_recording_screen.dart b/lib/screens/post_recording_screen.dart index b272dcf..7016cc1 100644 --- a/lib/screens/post_recording_screen.dart +++ b/lib/screens/post_recording_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:parachute/models/recording.dart'; -import 'package:parachute/services/audio_service.dart'; -import 'package:parachute/services/storage_service.dart'; -import 'package:parachute/services/whisper_service.dart'; +import 'package:parachute/providers/service_providers.dart'; import 'package:parachute/screens/settings_screen.dart'; +import 'package:parachute/services/whisper_service.dart'; -class PostRecordingScreen extends StatefulWidget { +class PostRecordingScreen extends ConsumerStatefulWidget { final String recordingPath; final Duration duration; final String? initialTranscript; @@ -18,15 +18,13 @@ class PostRecordingScreen extends StatefulWidget { }); @override - State createState() => _PostRecordingScreenState(); + ConsumerState createState() => + _PostRecordingScreenState(); } -class _PostRecordingScreenState extends State { +class _PostRecordingScreenState extends ConsumerState { final TextEditingController _titleController = TextEditingController(); final TextEditingController _transcriptController = TextEditingController(); - final StorageService _storageService = StorageService(); - final AudioService _audioService = AudioService(); - final WhisperService _whisperService = WhisperService(); final List _predefinedTags = [ 'Project A', @@ -57,10 +55,12 @@ class _PostRecordingScreenState extends State { Future _togglePlayback() async { if (_isPlaying) { - await _audioService.stopPlayback(); + await ref.read(audioServiceProvider).stopPlayback(); setState(() => _isPlaying = false); } else { - final success = await _audioService.playRecording(widget.recordingPath); + final success = await ref + .read(audioServiceProvider) + .playRecording(widget.recordingPath); if (success) { setState(() => _isPlaying = true); // Auto-stop after duration (simplified) @@ -77,7 +77,7 @@ class _PostRecordingScreenState extends State { if (_isTranscribing) return; // Check if API key is configured - final isConfigured = await _whisperService.isConfigured(); + final isConfigured = await ref.read(whisperServiceProvider).isConfigured(); if (!isConfigured) { if (!mounted) return; @@ -117,9 +117,9 @@ class _PostRecordingScreenState extends State { setState(() => _isTranscribing = true); try { - final transcript = await _whisperService.transcribeAudio( - widget.recordingPath, - ); + final transcript = await ref.read(whisperServiceProvider).transcribeAudio( + widget.recordingPath, + ); if (mounted) { _transcriptController.text = transcript; @@ -163,8 +163,9 @@ class _PostRecordingScreenState extends State { setState(() => _isSaving = true); try { - final fileSizeKB = - await _audioService.getFileSizeKB(widget.recordingPath); + final fileSizeKB = await ref + .read(audioServiceProvider) + .getFileSizeKB(widget.recordingPath); // Extract recording ID from the file path // Path format: /path/to/2025-10-06-1759784172526.m4a @@ -184,7 +185,8 @@ class _PostRecordingScreenState extends State { fileSizeKB: fileSizeKB, ); - final success = await _storageService.saveRecording(recording); + final success = + await ref.read(storageServiceProvider).saveRecording(recording); if (success && mounted) { // Show success message first @@ -197,7 +199,9 @@ class _PostRecordingScreenState extends State { // Navigate back to home screen and trigger refresh if (mounted) { - Navigator.of(context).popUntil((route) => route.isFirst); + if (!mounted) return; + final navigator = Navigator.of(context); + navigator.popUntil((route) => route.isFirst); } } else { ScaffoldMessenger.of(context).showSnackBar( @@ -281,7 +285,7 @@ class _PostRecordingScreenState extends State { Text( '${widget.duration.inMinutes}:${(widget.duration.inSeconds % 60).toString().padLeft(2, '0')}', style: TextStyle( - color: Colors.grey.withOpacity(0.7), + color: Colors.grey.withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/recording_detail_screen.dart b/lib/screens/recording_detail_screen.dart index 4a41c7c..a4919d9 100644 --- a/lib/screens/recording_detail_screen.dart +++ b/lib/screens/recording_detail_screen.dart @@ -1,10 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/material.dart'; +import 'package:parachute/providers/service_providers.dart'; import 'package:flutter/services.dart'; import 'package:parachute/models/recording.dart'; -import 'package:parachute/services/storage_service.dart'; import 'package:parachute/widgets/playback_controls.dart'; -class RecordingDetailScreen extends StatefulWidget { +class RecordingDetailScreen extends ConsumerStatefulWidget { final Recording recording; const RecordingDetailScreen({ @@ -13,11 +14,10 @@ class RecordingDetailScreen extends StatefulWidget { }); @override - State createState() => _RecordingDetailScreenState(); + ConsumerState createState() => _RecordingDetailScreenState(); } -class _RecordingDetailScreenState extends State { - final StorageService _storageService = StorageService(); +class _RecordingDetailScreenState extends ConsumerState { late Recording _recording; @override @@ -110,13 +110,20 @@ class _RecordingDetailScreenState extends State { ); final success = - await _storageService.updateRecording(updatedRecording); + await ref.read(storageServiceProvider).updateRecording(updatedRecording); if (success && mounted) { setState(() { _recording = updatedRecording; }); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( + if (!mounted) return; + + final navigator = Navigator.of(context); + + final messenger = ScaffoldMessenger.of(context); + + navigator.pop(); + + messenger.showSnackBar( const SnackBar(content: Text('Recording updated')), ); } @@ -191,7 +198,7 @@ class _RecordingDetailScreenState extends State { onPressed: () async { Navigator.pop(context); final success = - await _storageService.deleteRecording(_recording.id); + await ref.read(storageServiceProvider).deleteRecording(_recording.id); if (success && mounted) { Navigator.pop(context, true); ScaffoldMessenger.of(context).showSnackBar( @@ -233,7 +240,7 @@ class _RecordingDetailScreenState extends State { duration: _recording.duration, onDelete: () async { final success = - await _storageService.deleteRecording(_recording.id); + await ref.read(storageServiceProvider).deleteRecording(_recording.id); if (success && mounted) { Navigator.of(context).pop(true); } @@ -308,7 +315,7 @@ class _RecordingDetailScreenState extends State { Text( label, style: TextStyle( - color: Colors.grey.withOpacity(0.7), + color: Colors.grey.withValues(alpha: 0.7), fontSize: 12, ), ), @@ -372,10 +379,10 @@ class _RecordingDetailScreenState extends State { color: Theme.of(context) .colorScheme .surfaceContainerHighest - .withOpacity(0.3), + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.2), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), ), ), child: Text( @@ -386,7 +393,7 @@ class _RecordingDetailScreenState extends State { height: 1.5, color: _recording.transcript.isNotEmpty ? null - : Colors.grey.withOpacity(0.7), + : Colors.grey.withValues(alpha: 0.7), fontStyle: _recording.transcript.isEmpty ? FontStyle.italic : null, ), diff --git a/lib/screens/recording_screen.dart b/lib/screens/recording_screen.dart index f80c477..083468e 100644 --- a/lib/screens/recording_screen.dart +++ b/lib/screens/recording_screen.dart @@ -1,18 +1,20 @@ import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:parachute/services/audio_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:parachute/providers/service_providers.dart'; import 'package:parachute/screens/post_recording_screen.dart'; +import 'package:parachute/services/audio_service.dart'; import 'package:parachute/widgets/recording_visualizer.dart'; -class RecordingScreen extends StatefulWidget { +class RecordingScreen extends ConsumerStatefulWidget { const RecordingScreen({super.key}); @override - State createState() => _RecordingScreenState(); + ConsumerState createState() => _RecordingScreenState(); } -class _RecordingScreenState extends State { - final AudioService _audioService = AudioService(); +class _RecordingScreenState extends ConsumerState { RecordingState _recordingState = RecordingState.stopped; Duration _recordingDuration = Duration.zero; Duration _pausedDuration = Duration.zero; @@ -30,36 +32,29 @@ class _RecordingScreenState extends State { @override void dispose() { _timer?.cancel(); - // Don't dispose the AudioService singleton - it's shared across the app super.dispose(); } Future _initializeAndStartRecording() async { + final audioService = ref.read(audioServiceProvider); + try { // Initialize audio service (required) - print('Initializing audio service...'); - await _audioService.initialize(); - print('Audio service initialized'); + await audioService.initialize(); // Start audio recording - print('Starting audio recording...'); - final success = await _audioService.startRecording(); - + final success = await audioService.startRecording(); if (success) { - print('Audio recording started successfully'); _startTime = DateTime.now(); _startTimer(); - if (mounted) { setState(() { _recordingState = RecordingState.recording; }); } - // Try to initialize transcription (optional, non-blocking) _initializeTranscription(); } else { - print('Failed to start audio recording'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -74,10 +69,7 @@ class _RecordingScreenState extends State { } } } - } catch (e, stackTrace) { - print('Error in initializeAndStartRecording: $e'); - print('Stack trace: $stackTrace'); - + } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -104,20 +96,15 @@ class _RecordingScreenState extends State { // 3. Implement transcription after recording completes // // For now, transcription is left as a placeholder for future implementation. - - print( - 'Real-time transcription skipped - microphone conflict with audio recording'); } void _startTimer() { _timer?.cancel(); - _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { if (!mounted) { timer.cancel(); return; } - if (_recordingState == RecordingState.recording && _startTime != null) { setState(() { // Calculate total duration minus paused time @@ -131,25 +118,25 @@ class _RecordingScreenState extends State { } Future _pauseRecording() async { + final audioService = ref.read(audioServiceProvider); + if (_recordingState == RecordingState.recording) { - final success = await _audioService.pauseRecording(); + final success = await audioService.pauseRecording(); if (success) { _pauseStartTime = DateTime.now(); _timer?.cancel(); - setState(() { _recordingState = RecordingState.paused; }); } } else if (_recordingState == RecordingState.paused) { - final success = await _audioService.resumeRecording(); + final success = await audioService.resumeRecording(); if (success) { // Add the paused duration to total paused time if (_pauseStartTime != null) { _pausedDuration += DateTime.now().difference(_pauseStartTime!); _pauseStartTime = null; } - setState(() { _recordingState = RecordingState.recording; }); @@ -159,6 +146,8 @@ class _RecordingScreenState extends State { } Future _stopRecording() async { + final audioService = ref.read(audioServiceProvider); + _timer?.cancel(); setState(() { _recordingState = RecordingState.stopped; @@ -167,16 +156,15 @@ class _RecordingScreenState extends State { // Transcription is currently disabled due to microphone conflicts String transcription = ''; - final path = await _audioService.stopRecording(); + final path = await audioService.stopRecording(); + if (path != null && mounted) { _recordingPath = path; - // Calculate final duration if (_startTime != null) { final totalElapsed = DateTime.now().difference(_startTime!); _recordingDuration = totalElapsed - _pausedDuration; } - _navigateToPostRecording(transcription); } else { if (mounted) { @@ -226,7 +214,6 @@ class _RecordingScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(), - // Recording status Text( _recordingState == RecordingState.recording @@ -242,16 +229,12 @@ class _RecordingScreenState extends State { : Colors.grey, ), ), - const SizedBox(height: 20), - // Recording visualizer RecordingVisualizer( isRecording: _recordingState == RecordingState.recording, ), - const SizedBox(height: 30), - // Duration display Text( _formattedDuration, @@ -260,9 +243,7 @@ class _RecordingScreenState extends State { fontFamily: 'monospace', ), ), - const SizedBox(height: 20), - // Recording indicator if (_recordingState == RecordingState.recording) Row( @@ -280,11 +261,8 @@ class _RecordingScreenState extends State { const Text('recording...'), ], ), - const SizedBox(height: 20), - const Spacer(), - // Control buttons Padding( padding: const EdgeInsets.all(32.0), @@ -307,7 +285,6 @@ class _RecordingScreenState extends State { color: Colors.white, ), ), - // Stop button FloatingActionButton( heroTag: 'stopButton', @@ -325,7 +302,6 @@ class _RecordingScreenState extends State { ], ), ), - const Text('Tap stop to finish recording'), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 592e427..a156473 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,19 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:parachute/services/storage_service.dart'; +import 'package:parachute/providers/service_providers.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:file_picker/file_picker.dart'; -import 'dart:io'; -class SettingsScreen extends StatefulWidget { +class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @override - State createState() => _SettingsScreenState(); + ConsumerState createState() => _SettingsScreenState(); } -class _SettingsScreenState extends State { - final StorageService _storageService = StorageService(); +class _SettingsScreenState extends ConsumerState { final TextEditingController _apiKeyController = TextEditingController(); bool _isLoading = true; bool _isSaving = false; @@ -36,13 +34,13 @@ class _SettingsScreenState extends State { Future _loadApiKey() async { setState(() => _isLoading = true); - final apiKey = await _storageService.getOpenAIApiKey(); + final apiKey = await ref.read(storageServiceProvider).getOpenAIApiKey(); if (apiKey != null && apiKey.isNotEmpty) { _apiKeyController.text = apiKey; _hasApiKey = true; } - _syncFolderPath = await _storageService.getSyncFolderPath(); + _syncFolderPath = await ref.read(storageServiceProvider).getSyncFolderPath(); setState(() => _isLoading = false); } @@ -73,7 +71,7 @@ class _SettingsScreenState extends State { setState(() => _isSaving = true); - final success = await _storageService.saveOpenAIApiKey(apiKey); + final success = await ref.read(storageServiceProvider).saveOpenAIApiKey(apiKey); setState(() => _isSaving = false); @@ -123,7 +121,7 @@ class _SettingsScreenState extends State { ); if (confirmed == true) { - final success = await _storageService.deleteOpenAIApiKey(); + final success = await ref.read(storageServiceProvider).deleteOpenAIApiKey(); if (success) { _apiKeyController.clear(); setState(() => _hasApiKey = false); @@ -150,7 +148,7 @@ class _SettingsScreenState extends State { if (selectedDirectory != null) { final success = - await _storageService.setSyncFolderPath(selectedDirectory); + await ref.read(storageServiceProvider).setSyncFolderPath(selectedDirectory); if (success) { setState(() => _syncFolderPath = selectedDirectory); if (mounted) { @@ -206,7 +204,7 @@ class _SettingsScreenState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), + color: Colors.blue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.blue, width: 2), ), @@ -271,8 +269,8 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _hasApiKey - ? Colors.green.withOpacity(0.1) - : Colors.orange.withOpacity(0.1), + ? Colors.green.withValues(alpha: 0.1) + : Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all( color: _hasApiKey ? Colors.green : Colors.orange, @@ -383,7 +381,7 @@ class _SettingsScreenState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), + color: Colors.blue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Column( diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 32fddfd..7d7a9b6 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -1,9 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'dart:async'; import 'dart:io'; import 'package:record/record.dart'; import 'package:just_audio/just_audio.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:parachute/models/recording.dart'; import 'package:parachute/services/storage_service.dart'; enum RecordingState { @@ -36,25 +36,25 @@ class AudioService { Future initialize() async { if (_isInitialized) { - print('AudioService already initialized'); + debugPrint('AudioService already initialized'); return; } try { - print('Initializing AudioService...'); + debugPrint('Initializing AudioService...'); // Check if recording is supported if (await _recorder.hasPermission()) { - print('Recording permissions granted'); + debugPrint('Recording permissions granted'); } else { - print('Recording permissions not granted'); + debugPrint('Recording permissions not granted'); } _isInitialized = true; - print('AudioService initialized successfully'); + debugPrint('AudioService initialized successfully'); } catch (e, stackTrace) { - print('Error initializing AudioService: $e'); - print('Stack trace: $stackTrace'); + debugPrint('Error initializing AudioService: $e'); + debugPrint('Stack trace: $stackTrace'); _isInitialized = false; rethrow; } @@ -65,7 +65,7 @@ class AudioService { await _recorder.dispose(); await _player.dispose(); _isInitialized = false; - print('AudioService disposed'); + debugPrint('AudioService disposed'); } Future requestPermissions() async { @@ -73,21 +73,21 @@ class AudioService { // Use the record package's built-in permission handling // which works across all platforms including macOS final hasPermission = await _recorder.hasPermission(); - print('Recording permission check: $hasPermission'); + debugPrint('Recording permission check: $hasPermission'); if (!hasPermission) { - print('Microphone permission denied'); + debugPrint('Microphone permission denied'); // On Android, try to open settings if permission is denied if (Platform.isAndroid) { try { final micPermission = await Permission.microphone.status; if (micPermission.isPermanentlyDenied) { - print('Opening app settings for permission...'); + debugPrint('Opening app settings for permission...'); await openAppSettings(); } } catch (e) { - print('Could not open settings: $e'); + debugPrint('Could not open settings: $e'); } } @@ -100,22 +100,22 @@ class AudioService { if (await Permission.notification.isDenied) { final notificationPermission = await Permission.notification.request(); - print('Android Notification permission: $notificationPermission'); + debugPrint('Android Notification permission: $notificationPermission'); } } catch (e) { - print('Could not request notification permission: $e'); + debugPrint('Could not request notification permission: $e'); // Not critical, continue anyway } } return true; } catch (e) { - print('Error requesting permissions: $e'); + debugPrint('Error requesting permissions: $e'); // If there's an error but the recorder says it has permission, trust it try { return await _recorder.hasPermission(); } catch (e2) { - print('Fallback permission check failed: $e2'); + debugPrint('Fallback permission check failed: $e2'); return false; } } @@ -131,10 +131,10 @@ class AudioService { // Use M4A format for better compatibility (works with Whisper API) final path = '$syncFolder/$dateStr-$recordingId.m4a'; - print('Generated recording path: $path'); + debugPrint('Generated recording path: $path'); return path; } catch (e) { - print('Error getting recording path: $e'); + debugPrint('Error getting recording path: $e'); rethrow; } } @@ -150,44 +150,44 @@ class AudioService { } Future startRecording() async { - print('startRecording called, current state: $_recordingState'); + debugPrint('startRecording called, current state: $_recordingState'); if (_recordingState != RecordingState.stopped) { - print('Cannot start recording: state is $_recordingState'); + debugPrint('Cannot start recording: state is $_recordingState'); return false; } // Check and request permissions final hasPermission = await requestPermissions(); - print('Permission check result: $hasPermission'); + debugPrint('Permission check result: $hasPermission'); if (!hasPermission) { - print('Permission denied, cannot start recording'); + debugPrint('Permission denied, cannot start recording'); return false; } try { // Ensure recorder is properly initialized if (!_isInitialized) { - print('Recorder not initialized, initializing now...'); + debugPrint('Recorder not initialized, initializing now...'); await initialize(); } // Check if already recording if (await _recorder.isRecording()) { - print('Recorder is already recording'); + debugPrint('Recorder is already recording'); return false; } // Generate recording ID and path - print('Generating recording ID...'); + debugPrint('Generating recording ID...'); final recordingId = DateTime.now().millisecondsSinceEpoch.toString(); - print('Recording ID: $recordingId'); + debugPrint('Recording ID: $recordingId'); - print('Getting recording path...'); + debugPrint('Getting recording path...'); _currentRecordingPath = await _getRecordingPath(recordingId); - print('Will record to: $_currentRecordingPath'); + debugPrint('Will record to: $_currentRecordingPath'); // Start recording with M4A AAC format (compatible with Whisper API) - print('Starting recorder...'); + debugPrint('Starting recorder...'); await _recorder.start( const RecordConfig( encoder: AudioEncoder.aacLc, @@ -196,7 +196,7 @@ class AudioService { ), path: _currentRecordingPath!, ); - print('Recorder.start() completed'); + debugPrint('Recorder.start() completed'); _recordingStartTime = DateTime.now(); _recordingState = RecordingState.recording; @@ -204,11 +204,11 @@ class AudioService { _pausedDuration = Duration.zero; _startDurationTimer(); - print('Recording started successfully'); + debugPrint('Recording started successfully'); return true; } catch (e, stackTrace) { - print('Error starting recording: $e'); - print('Stack trace: $stackTrace'); + debugPrint('Error starting recording: $e'); + debugPrint('Stack trace: $stackTrace'); _recordingState = RecordingState.stopped; _currentRecordingPath = null; return false; @@ -223,10 +223,10 @@ class AudioService { _recordingState = RecordingState.paused; _pauseStartTime = DateTime.now(); _durationTimer?.cancel(); - print('Recording paused'); + debugPrint('Recording paused'); return true; } catch (e) { - print('Error pausing recording: $e'); + debugPrint('Error pausing recording: $e'); return false; } } @@ -245,10 +245,10 @@ class AudioService { } _startDurationTimer(); - print('Recording resumed'); + debugPrint('Recording resumed'); return true; } catch (e) { - print('Error resuming recording: $e'); + debugPrint('Error resuming recording: $e'); return false; } } @@ -271,17 +271,17 @@ class AudioService { final file = File(path); if (await file.exists()) { final size = await file.length(); - print('Recording stopped and saved: $path (size: ${size / 1024}KB)'); + debugPrint('Recording stopped and saved: $path (size: ${size / 1024}KB)'); return path; } else { - print('Recording file not found at: $path'); + debugPrint('Recording file not found at: $path'); } } - print('Recording stopped but file not found'); + debugPrint('Recording stopped but file not found'); return null; } catch (e) { - print('Error stopping recording: $e'); + debugPrint('Error stopping recording: $e'); _recordingState = RecordingState.stopped; _durationTimer?.cancel(); return null; @@ -291,24 +291,24 @@ class AudioService { Future playRecording(String filePath) async { try { if (filePath.isEmpty) { - print('Cannot play: empty file path'); + debugPrint('Cannot play: empty file path'); return false; } final file = File(filePath); if (!await file.exists()) { - print('File not found: $filePath'); + debugPrint('File not found: $filePath'); return false; } await _player.setFilePath(filePath); await _player.play(); - print('Playing recording: $filePath'); + debugPrint('Playing recording: $filePath'); return true; } catch (e, stackTrace) { - print('Error playing recording: $e'); - print('Stack trace: $stackTrace'); + debugPrint('Error playing recording: $e'); + debugPrint('Stack trace: $stackTrace'); return false; } } @@ -316,10 +316,10 @@ class AudioService { Future stopPlayback() async { try { await _player.stop(); - print('Playback stopped'); + debugPrint('Playback stopped'); return true; } catch (e) { - print('Error stopping playback: $e'); + debugPrint('Error stopping playback: $e'); return false; } } @@ -329,7 +329,7 @@ class AudioService { await _player.pause(); return true; } catch (e) { - print('Error pausing playback: $e'); + debugPrint('Error pausing playback: $e'); return false; } } @@ -339,7 +339,7 @@ class AudioService { await _player.play(); return true; } catch (e) { - print('Error resuming playback: $e'); + debugPrint('Error resuming playback: $e'); return false; } } @@ -354,7 +354,7 @@ class AudioService { await _player.setFilePath(filePath); return _player.duration; } catch (e) { - print('Error getting recording duration: $e'); + debugPrint('Error getting recording duration: $e'); return null; } } @@ -368,7 +368,7 @@ class AudioService { } return 0; } catch (e) { - print('Error getting file size: $e'); + debugPrint('Error getting file size: $e'); return 0; } } @@ -376,21 +376,21 @@ class AudioService { Future deleteRecordingFile(String filePath) async { try { if (filePath.isEmpty) { - print('Cannot delete: empty file path'); + debugPrint('Cannot delete: empty file path'); return false; } final file = File(filePath); if (await file.exists()) { await file.delete(); - print('Deleted recording file: $filePath'); + debugPrint('Deleted recording file: $filePath'); return true; } - print('File not found for deletion: $filePath'); + debugPrint('File not found for deletion: $filePath'); return false; } catch (e) { - print('Error deleting recording file: $e'); + debugPrint('Error deleting recording file: $e'); return false; } } diff --git a/lib/services/speech_service_old.dart b/lib/services/speech_service_old.dart deleted file mode 100644 index be75cee..0000000 --- a/lib/services/speech_service_old.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter/foundation.dart'; -// Note: speech_to_text dependency removed - using OpenAI Whisper API instead -// import 'package:speech_to_text/speech_recognition_result.dart'; -// import 'package:speech_to_text/speech_to_text.dart'; - -class SpeechService { - final SpeechToText _speechToText = SpeechToText(); - bool _speechEnabled = false; - String _lastWords = ''; - Function(String)? _onResult; - - bool get isListening => _speechToText.isListening; - bool get isAvailable => _speechEnabled; - String get lastWords => _lastWords; - - /// Initialize speech recognition - Future initialize() async { - try { - _speechEnabled = await _speechToText.initialize( - onStatus: (status) => debugPrint('Speech status: $status'), - onError: (error) { - debugPrint('Speech error: $error'); - debugPrint('Error type: ${error.errorMsg}'); - debugPrint('Permanent: ${error.permanent}'); - }, - debugLogging: true, - ); - - if (_speechEnabled) { - debugPrint('Speech recognition initialized successfully'); - // Check if microphone is available - final micAvailable = await _speechToText.hasPermission; - debugPrint('Microphone permission: $micAvailable'); - } else { - debugPrint('Speech recognition not available'); - } - - return _speechEnabled; - } catch (e) { - debugPrint('Failed to initialize speech: $e'); - return false; - } - } - - /// Start listening for speech - Future startListening({Function(String)? onResult}) async { - // Set the callback first so we can report errors - _onResult = onResult; - _lastWords = ''; - - if (!_speechEnabled) { - debugPrint('Speech not enabled, initializing...'); - final initialized = await initialize(); - if (!initialized) { - debugPrint('ERROR: Failed to initialize speech recognition!'); - if (_onResult != null) { - _onResult!('[Speech recognition failed to initialize]'); - } - return; - } - } - - try { - // Check browser compatibility for web - if (kIsWeb) { - debugPrint('Running on web - checking browser compatibility'); - // Web Speech API is best supported in Chrome/Edge - } - - // The listen() method might return void on some platforms - await _speechToText.listen( - onResult: _onSpeechResult, - listenOptions: SpeechListenOptions( - listenMode: ListenMode.confirmation, - partialResults: true, - cancelOnError: false, // Don't cancel on error - autoPunctuation: true, - ), - ); - - // Check if actually listening - if (_speechToText.isListening) { - debugPrint('Started listening successfully'); - } else { - debugPrint('ERROR: Failed to start listening - not in listening state'); - if (_onResult != null) { - _onResult!('[Speech recognition failed to start]'); - } - } - } catch (e) { - debugPrint('Error starting speech recognition: $e'); - if (_onResult != null) { - _onResult!('[Speech error: $e]'); - } - } - } - - /// Stop listening - Future stopListening() async { - if (_speechToText.isListening) { - await _speechToText.stop(); - debugPrint('Stopped listening. Final result: $_lastWords'); - } else { - debugPrint('Speech service was not listening'); - } - } - - /// Cancel listening without getting results - Future cancelListening() async { - await _speechToText.cancel(); - _lastWords = ''; - debugPrint('Cancelled listening'); - } - - /// Handle speech recognition results - void _onSpeechResult(SpeechRecognitionResult result) { - _lastWords = result.recognizedWords; - debugPrint( - 'Recognition result: "$_lastWords" (final: ${result.finalResult}, confidence: ${result.confidence})'); - - if (_onResult != null) { - _onResult!(_lastWords); - } - } - - /// Get available locales - Future> getLocales() async { - try { - final locales = await _speechToText.locales(); - return locales; - } catch (e) { - debugPrint('Error getting locales: $e'); - return []; - } - } - - void dispose() { - _speechToText.cancel(); - } -} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 021265a..5389c6c 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,5 +1,5 @@ +import 'package:flutter/foundation.dart'; import 'dart:io'; -import 'dart:convert'; import 'package:parachute/models/recording.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -42,41 +42,41 @@ class StorageService { Future _doInitialize() async { try { - print('StorageService: Starting initialization...'); + debugPrint('StorageService: Starting initialization...'); final prefs = await SharedPreferences.getInstance(); - print('StorageService: Got SharedPreferences'); + debugPrint('StorageService: Got SharedPreferences'); _syncFolderPath = prefs.getString(_syncFolderPathKey); - print('StorageService: Sync folder path: $_syncFolderPath'); + debugPrint('StorageService: Sync folder path: $_syncFolderPath'); // If no sync folder is set, use default app documents directory if (_syncFolderPath == null) { - print('StorageService: Getting app documents directory...'); + debugPrint('StorageService: Getting app documents directory...'); final appDir = await getApplicationDocumentsDirectory(); _syncFolderPath = '${appDir.path}/parachute_recordings'; - print('StorageService: Set default sync folder: $_syncFolderPath'); + debugPrint('StorageService: Set default sync folder: $_syncFolderPath'); await prefs.setString(_syncFolderPathKey, _syncFolderPath!); } // Ensure recordings directory exists - print('StorageService: Ensuring recordings directory exists...'); + debugPrint('StorageService: Ensuring recordings directory exists...'); await _ensureRecordingsDirectory(); // Create sample recordings on first launch final hasInitialized = prefs.getBool(_hasInitializedKey) ?? false; - print('StorageService: Has initialized: $hasInitialized'); + debugPrint('StorageService: Has initialized: $hasInitialized'); if (!hasInitialized) { - print('StorageService: Creating sample recordings...'); + debugPrint('StorageService: Creating sample recordings...'); await _createSampleRecordings(); await prefs.setBool(_hasInitializedKey, true); } _isInitialized = true; _initializationFuture = null; - print('StorageService: Initialization complete'); + debugPrint('StorageService: Initialization complete'); } catch (e, stackTrace) { - print('StorageService: Error during initialization: $e'); - print('StorageService: Stack trace: $stackTrace'); + debugPrint('StorageService: Error during initialization: $e'); + debugPrint('StorageService: Stack trace: $stackTrace'); _initializationFuture = null; rethrow; } @@ -103,7 +103,7 @@ class StorageService { await _ensureRecordingsDirectory(); return true; } catch (e) { - print('Error setting sync folder path: $e'); + debugPrint('Error setting sync folder path: $e'); return false; } } @@ -112,7 +112,7 @@ class StorageService { final recordingsDir = Directory(_syncFolderPath!); if (!await recordingsDir.exists()) { await recordingsDir.create(recursive: true); - print('Created recordings directory: ${recordingsDir.path}'); + debugPrint('Created recordings directory: ${recordingsDir.path}'); } } @@ -153,7 +153,7 @@ class StorageService { recordings.add(recording); } } catch (e) { - print('Error loading recording from ${entity.path}: $e'); + debugPrint('Error loading recording from ${entity.path}: $e'); } } } @@ -162,7 +162,7 @@ class StorageService { recordings.sort((a, b) => b.timestamp.compareTo(a.timestamp)); return recordings; } catch (e) { - print('Error getting recordings: $e'); + debugPrint('Error getting recordings: $e'); return []; } } @@ -174,7 +174,7 @@ class StorageService { // Parse frontmatter and content final parts = content.split('---'); if (parts.length < 3) { - print('Invalid markdown format in ${mdFile.path}'); + debugPrint('Invalid markdown format in ${mdFile.path}'); return null; } @@ -260,10 +260,10 @@ class StorageService { final markdown = _generateMarkdown(recording); await mdFile.writeAsString(markdown); - print('Saved recording metadata: $mdPath'); + debugPrint('Saved recording metadata: $mdPath'); return true; } catch (e) { - print('Error saving recording: $e'); + debugPrint('Error saving recording: $e'); return false; } } @@ -322,7 +322,7 @@ class StorageService { final audioFile = File(recording.filePath); if (await audioFile.exists()) { await audioFile.delete(); - print('Deleted audio file: ${recording.filePath}'); + debugPrint('Deleted audio file: ${recording.filePath}'); } // Delete metadata file @@ -330,12 +330,12 @@ class StorageService { final mdFile = File(mdPath); if (await mdFile.exists()) { await mdFile.delete(); - print('Deleted metadata file: $mdPath'); + debugPrint('Deleted metadata file: $mdPath'); } return true; } catch (e) { - print('Error deleting recording: $e'); + debugPrint('Error deleting recording: $e'); return false; } } @@ -420,7 +420,7 @@ class StorageService { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_openaiApiKeyKey); } catch (e) { - print('Error getting OpenAI API key: $e'); + debugPrint('Error getting OpenAI API key: $e'); return null; } } @@ -430,7 +430,7 @@ class StorageService { final prefs = await SharedPreferences.getInstance(); return await prefs.setString(_openaiApiKeyKey, apiKey.trim()); } catch (e) { - print('Error saving OpenAI API key: $e'); + debugPrint('Error saving OpenAI API key: $e'); return false; } } @@ -440,7 +440,7 @@ class StorageService { final prefs = await SharedPreferences.getInstance(); return await prefs.remove(_openaiApiKeyKey); } catch (e) { - print('Error deleting OpenAI API key: $e'); + debugPrint('Error deleting OpenAI API key: $e'); return false; } } diff --git a/lib/services/transcription_service.dart b/lib/services/transcription_service.dart deleted file mode 100644 index 614ce4d..0000000 --- a/lib/services/transcription_service.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'dart:async'; -// Note: speech_to_text dependency removed - using OpenAI Whisper API instead -// import 'package:speech_to_text/speech_to_text.dart'; -// import 'package:speech_to_text/speech_recognition_result.dart'; - -class TranscriptionService { - static final TranscriptionService _instance = - TranscriptionService._internal(); - factory TranscriptionService() => _instance; - TranscriptionService._internal(); - - final SpeechToText _speechToText = SpeechToText(); - bool _isInitialized = false; - bool _isListening = false; - String _transcription = ''; - StreamController? _transcriptionController; - - bool get isInitialized => _isInitialized; - bool get isListening => _isListening; - String get transcription => _transcription; - Stream? get transcriptionStream => _transcriptionController?.stream; - - Future initialize() async { - if (_isInitialized) return true; - - try { - print('Requesting speech recognition permissions...'); - _isInitialized = await _speechToText.initialize( - onStatus: (status) { - print('Speech recognition status: $status'); - if (status == 'notListening' && _isListening) { - print( - 'Speech recognition stopped unexpectedly, may have timed out'); - } - }, - onError: (error) { - print('Speech recognition error: ${error.errorMsg}'); - print('Error type: ${error.permanent}'); - }, - debugLogging: true, - ); - - if (_isInitialized) { - print('TranscriptionService initialized successfully'); - - // Get available locales - final locales = await _speechToText.locales(); - print( - 'Available locales: ${locales.map((l) => l.localeId).join(', ')}'); - - // Check if the system locale is available - final systemLocale = await _speechToText.systemLocale(); - print('System locale: ${systemLocale?.localeId}'); - } else { - print( - 'TranscriptionService initialization failed - permission denied or not available'); - } - - return _isInitialized; - } catch (e) { - print('Error initializing TranscriptionService: $e'); - return false; - } - } - - Future startListening({ - Function(String)? onResult, - String? localeId, - }) async { - if (!_isInitialized) { - await initialize(); - } - - if (!_isInitialized) { - print('Cannot start listening: not initialized'); - return; - } - - if (_isListening) { - print('Already listening, skipping startListening call'); - return; - } - - _transcription = ''; - _transcriptionController = StreamController.broadcast(); - - try { - await _speechToText.listen( - onResult: (SpeechRecognitionResult result) { - _transcription = result.recognizedWords; - - print( - 'Transcription result: $_transcription (final: ${result.finalResult})'); - - // Emit the transcription through the stream - _transcriptionController?.add(_transcription); - - // Call the callback if provided - onResult?.call(_transcription); - - // If this is the final result, we might want to restart listening - // to continue transcribing (for long recordings) - if (result.finalResult && _isListening) { - // Automatically restart listening for continuous transcription - _restartListening(onResult: onResult, localeId: localeId); - } - }, - listenFor: const Duration(minutes: 10), - pauseFor: const Duration(seconds: 10), - partialResults: true, - localeId: localeId, - cancelOnError: false, - listenMode: ListenMode.dictation, - ); - - _isListening = true; - print('Started listening for speech'); - } catch (e) { - print('Error starting speech recognition: $e'); - _isListening = false; - } - } - - Future _restartListening({ - Function(String)? onResult, - String? localeId, - }) async { - // Small delay before restarting - await Future.delayed(const Duration(milliseconds: 100)); - - if (_isListening) { - try { - await _speechToText.listen( - onResult: (SpeechRecognitionResult result) { - // Append to existing transcription with a space - _transcription = _transcription.isEmpty - ? result.recognizedWords - : '$_transcription ${result.recognizedWords}'; - - _transcriptionController?.add(_transcription); - onResult?.call(_transcription); - - if (result.finalResult && _isListening) { - _restartListening(onResult: onResult, localeId: localeId); - } - }, - listenFor: const Duration(minutes: 10), - pauseFor: const Duration(seconds: 10), - partialResults: true, - localeId: localeId, - cancelOnError: false, - listenMode: ListenMode.dictation, - ); - } catch (e) { - print('Error restarting speech recognition: $e'); - } - } - } - - Future stopListening() async { - if (!_isListening) return; - - try { - await _speechToText.stop(); - _isListening = false; - _transcriptionController?.close(); - _transcriptionController = null; - print('Stopped listening for speech'); - } catch (e) { - print('Error stopping speech recognition: $e'); - } - } - - Future pauseListening() async { - if (!_isListening) return; - - try { - await _speechToText.stop(); - _isListening = false; - print('Paused listening for speech'); - } catch (e) { - print('Error pausing speech recognition: $e'); - } - } - - Future resumeListening({ - Function(String)? onResult, - String? localeId, - }) async { - if (_isListening) return; // Already listening - - // Don't clear transcription on resume, keep accumulating - if (_transcriptionController == null || - _transcriptionController!.isClosed) { - _transcriptionController = StreamController.broadcast(); - } - - try { - await _speechToText.listen( - onResult: (SpeechRecognitionResult result) { - // Append to existing transcription - final newWords = result.recognizedWords; - if (newWords.isNotEmpty) { - _transcription = - _transcription.isEmpty ? newWords : '$_transcription $newWords'; - _transcriptionController?.add(_transcription); - onResult?.call(_transcription); - } - - if (result.finalResult && _isListening) { - _restartListening(onResult: onResult, localeId: localeId); - } - }, - listenFor: const Duration(minutes: 10), - pauseFor: const Duration(seconds: 10), - partialResults: true, - localeId: localeId, - cancelOnError: false, - listenMode: ListenMode.dictation, - ); - _isListening = true; - print('Resumed listening for speech'); - } catch (e) { - print('Error resuming speech recognition: $e'); - _isListening = false; - } - } - - String getFinalTranscription() { - return _transcription; - } - - void clearTranscription() { - _transcription = ''; - _transcriptionController?.add(''); - } - - Future dispose() async { - await stopListening(); - _transcriptionController?.close(); - _isInitialized = false; - } - - Future hasPermission() async { - if (!_isInitialized) { - await initialize(); - } - return _isInitialized; - } -} diff --git a/lib/services/whisper_service.dart b/lib/services/whisper_service.dart index 916fdef..127991b 100644 --- a/lib/services/whisper_service.dart +++ b/lib/services/whisper_service.dart @@ -12,7 +12,9 @@ import 'package:parachute/services/storage_service.dart'; /// Cost: ~$0.006 per minute of audio /// Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm class WhisperService { - final StorageService _storageService = StorageService(); + final StorageService _storageService; + + WhisperService(this._storageService); /// Transcribes an audio file using OpenAI's Whisper API /// @@ -94,13 +96,13 @@ class WhisperService { ); } } - } on SocketException catch (e) { + } on SocketException { throw WhisperException( 'Network error: Please check your internet connection', ); - } on FormatException catch (e) { + } on FormatException { throw WhisperException( - 'Invalid response from Whisper API: ${e.message}', + 'Invalid response from Whisper API', ); } catch (e) { if (e is WhisperException) rethrow; diff --git a/lib/utils/sample_data.dart b/lib/utils/sample_data.dart deleted file mode 100644 index c2db58e..0000000 --- a/lib/utils/sample_data.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:parachute/models/recording.dart'; - -class SampleData { - static List getSampleRecordings() { - return [ - Recording( - id: '1', - title: 'Home automation notes', - filePath: '/sample/path1.aac', - timestamp: DateTime.now().subtract(const Duration(hours: 3)), - duration: const Duration(minutes: 2, seconds: 15), - tags: ['Project A', 'Ideas'], - transcript: '''Notes about setting up home automation system. -Key points: -- Smart thermostats need WiFi connection -- Motion sensors for automatic lighting -- Voice control integration with existing speakers -- Security considerations for IoT devices''', - fileSizeKB: 580.5, - ), - Recording( - id: '2', - title: 'Class overview notes', - filePath: '/sample/path2.aac', - timestamp: DateTime.now().subtract(const Duration(hours: 5)), - duration: const Duration(minutes: 4, seconds: 32), - tags: ['Meeting', 'Important'], - transcript: '''Discussion about upcoming class assignments and project deadlines. - -Professor mentioned: -- Final project due in 3 weeks -- Midterm exam next Tuesday -- Office hours extended for project help -- Study group formation encouraged''', - fileSizeKB: 1245.2, - ), - Recording( - id: '3', - title: 'Client meeting recap', - filePath: '/sample/path3.aac', - timestamp: DateTime.now().subtract(const Duration(days: 1, hours: 2)), - duration: const Duration(minutes: 1, seconds: 45), - tags: ['Meeting', 'To Do'], - transcript: '''Quick recap of client meeting today. - -Action items: -- Send revised proposal by Friday -- Schedule follow-up call for next week -- Update project timeline -- Review budget considerations''', - fileSizeKB: 420.8, - ), - Recording( - id: '4', - title: 'Interview preparation', - filePath: '/sample/path4.aac', - timestamp: DateTime.now().subtract(const Duration(days: 2)), - duration: const Duration(minutes: 6, seconds: 18), - tags: ['Interview', 'Important'], - transcript: '''Practice answers for upcoming job interview. - -Common questions to prepare: -- Tell me about yourself -- Why do you want this position? -- What are your strengths and weaknesses? -- Where do you see yourself in 5 years? -- Do you have any questions for us? - -Remember to research the company thoroughly and prepare specific examples.''', - fileSizeKB: 1850.3, - ), - ]; - } - - static void addSampleDataIfEmpty() async { - // This would be called on first launch to populate with sample data - // In a real app, you might want to add this conditionally - } -} \ No newline at end of file diff --git a/lib/utils/validators.dart b/lib/utils/validators.dart new file mode 100644 index 0000000..4480ba8 --- /dev/null +++ b/lib/utils/validators.dart @@ -0,0 +1,69 @@ +/// Input validation utilities for the Parachute app +class Validators { + /// Validates that a string is not empty and doesn't exceed max length + static String? validateTitle(String? value, {int maxLength = 100}) { + if (value == null || value.trim().isEmpty) { + return 'Title cannot be empty'; + } + + if (value.trim().length > maxLength) { + return 'Title must be $maxLength characters or less'; + } + + return null; + } + + /// Validates an OpenAI API key format + /// OpenAI keys start with 'sk-' and are typically 51 characters + static String? validateApiKey(String? value) { + if (value == null || value.trim().isEmpty) { + return 'API key cannot be empty'; + } + + final trimmed = value.trim(); + + if (!trimmed.startsWith('sk-')) { + return 'Invalid API key format (should start with sk-)'; + } + + if (trimmed.length < 20) { + return 'API key appears too short'; + } + + return null; + } + + /// Validates tag input + static String? validateTag(String? value, {int maxLength = 50}) { + if (value == null || value.trim().isEmpty) { + return null; // Tags are optional + } + + if (value.trim().length > maxLength) { + return 'Tag must be $maxLength characters or less'; + } + + // Ensure tags don't contain special characters + final invalidChars = RegExp(r'[^\w\s-]'); + if (invalidChars.hasMatch(value)) { + return 'Tags can only contain letters, numbers, spaces, and hyphens'; + } + + return null; + } + + /// Sanitizes user input by trimming whitespace + static String sanitize(String input) { + return input.trim(); + } + + /// Validates file path exists and is accessible + static bool isValidFilePath(String? path) { + if (path == null || path.isEmpty) { + return false; + } + + // Basic validation - actual file existence should be checked elsewhere + return path.contains('/') || path.contains('\\'); + } +} diff --git a/lib/widgets/playback_controls.dart b/lib/widgets/playback_controls.dart index 50675d3..4c19fa1 100644 --- a/lib/widgets/playback_controls.dart +++ b/lib/widgets/playback_controls.dart @@ -1,8 +1,10 @@ -import 'package:flutter/material.dart'; -import 'package:parachute/services/audio_service.dart'; import 'dart:async'; -class PlaybackControls extends StatefulWidget { +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:parachute/providers/service_providers.dart'; + +class PlaybackControls extends ConsumerStatefulWidget { final String filePath; final Duration duration; final VoidCallback? onDelete; @@ -15,11 +17,10 @@ class PlaybackControls extends StatefulWidget { }); @override - State createState() => _PlaybackControlsState(); + ConsumerState createState() => _PlaybackControlsState(); } -class _PlaybackControlsState extends State { - final AudioService _audioService = AudioService(); +class _PlaybackControlsState extends ConsumerState { bool _isPlaying = false; bool _isPaused = false; Duration _currentPosition = Duration.zero; @@ -32,30 +33,33 @@ class _PlaybackControlsState extends State { } Future _initializeAudio() async { - await _audioService.initialize(); + final audioService = ref.read(audioServiceProvider); + await audioService.initialize(); } @override void dispose() { - // Clean up without calling setState _progressTimer?.cancel(); if (_isPlaying) { - _audioService.stopPlayback(); + final audioService = ref.read(audioServiceProvider); + audioService.stopPlayback(); } super.dispose(); } Future _togglePlayback() async { + final audioService = ref.read(audioServiceProvider); + if (_isPlaying && !_isPaused) { // Pause playback - await _audioService.pausePlayback(); + await audioService.pausePlayback(); setState(() { _isPaused = true; }); _progressTimer?.cancel(); } else if (_isPaused) { // Resume playback - await _audioService.resumePlayback(); + await audioService.resumePlayback(); setState(() { _isPaused = false; }); @@ -72,7 +76,7 @@ class _PlaybackControlsState extends State { return; } - final success = await _audioService.playRecording(widget.filePath); + final success = await audioService.playRecording(widget.filePath); if (success) { setState(() { _isPlaying = true; @@ -80,7 +84,6 @@ class _PlaybackControlsState extends State { _currentPosition = Duration.zero; }); _startProgressTimer(); - // Auto-stop when playback completes Future.delayed(widget.duration, () { if (_isPlaying && mounted) { @@ -116,9 +119,11 @@ class _PlaybackControlsState extends State { } Future _stopPlayback() async { + final audioService = ref.read(audioServiceProvider); + _progressTimer?.cancel(); if (_isPlaying) { - await _audioService.stopPlayback(); + await audioService.stopPlayback(); } if (mounted) { setState(() { @@ -167,7 +172,6 @@ class _PlaybackControlsState extends State { ), ), const SizedBox(height: 8), - // Time display Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -183,7 +187,6 @@ class _PlaybackControlsState extends State { ], ), const SizedBox(height: 8), - // Control buttons Row( mainAxisAlignment: MainAxisAlignment.center, @@ -195,7 +198,6 @@ class _PlaybackControlsState extends State { onPressed: _stopPlayback, tooltip: 'Stop', ), - // Play/Pause button IconButton( icon: Icon( @@ -208,7 +210,6 @@ class _PlaybackControlsState extends State { color: Theme.of(context).colorScheme.primary, tooltip: _isPlaying && !_isPaused ? 'Pause' : 'Play', ), - // Delete button if (widget.onDelete != null) IconButton( diff --git a/pubspec.lock b/pubspec.lock index 9bf76da..7721891 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" archive: dependency: transitive description: @@ -41,6 +65,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" characters: dependency: transitive description: @@ -73,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" collection: dependency: transitive description: @@ -81,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -105,6 +209,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" fake_async: dependency: transitive description: @@ -174,6 +302,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.30" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter @@ -184,6 +320,30 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" google_fonts: dependency: "direct main" description: @@ -192,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: "direct main" description: @@ -200,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -216,6 +392,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -280,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -304,6 +504,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -432,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -440,6 +664,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" record: dependency: "direct main" description: @@ -504,6 +744,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.7" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + url: "https://pub.dev" + source: hosted + version: "0.5.10" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + url: "https://pub.dev" + source: hosted + version: "2.6.5" rxdart: dependency: transitive description: @@ -568,11 +840,35 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" source_span: dependency: transitive description: @@ -597,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -605,6 +909,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -637,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -733,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" web: dependency: transitive description: @@ -741,6 +1069,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1e2ca58..7a585e2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,12 +19,18 @@ dependencies: http: ^1.2.0 url_launcher: ^6.3.0 file_picker: ^8.0.0+1 + # State management + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + # Code generation for Riverpod + build_runner: ^2.4.13 + riverpod_generator: ^2.6.2 flutter: uses-material-design: true diff --git a/test/models/recording_test.dart b/test/models/recording_test.dart new file mode 100644 index 0000000..98a3aeb --- /dev/null +++ b/test/models/recording_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:parachute/models/recording.dart'; + +void main() { + group('Recording Model', () { + test('should create a valid recording', () { + final recording = Recording( + id: 'test_123', + title: 'Test Recording', + filePath: '/path/to/file.m4a', + timestamp: DateTime(2025, 1, 1), + duration: const Duration(minutes: 5), + tags: ['test', 'demo'], + transcript: 'Test transcript', + fileSizeKB: 1024.5, + ); + + expect(recording.id, 'test_123'); + expect(recording.title, 'Test Recording'); + expect(recording.duration, const Duration(minutes: 5)); + expect(recording.tags.length, 2); + }); + + test('should format duration correctly', () { + final recording = Recording( + id: 'test', + title: 'Test', + filePath: '/path', + timestamp: DateTime.now(), + duration: const Duration(minutes: 3, seconds: 45), + tags: [], + transcript: '', + fileSizeKB: 0, + ); + + expect(recording.durationString, '03:45'); + }); + + test('should format file size in KB', () { + final recording = Recording( + id: 'test', + title: 'Test', + filePath: '/path', + timestamp: DateTime.now(), + duration: Duration.zero, + tags: [], + transcript: '', + fileSizeKB: 500.5, + ); + + expect(recording.formattedSize, '500.5KB'); + }); + + test('should format file size in MB', () { + final recording = Recording( + id: 'test', + title: 'Test', + filePath: '/path', + timestamp: DateTime.now(), + duration: Duration.zero, + tags: [], + transcript: '', + fileSizeKB: 2048.0, + ); + + expect(recording.formattedSize, '2.0MB'); + }); + + test('should convert to JSON', () { + final recording = Recording( + id: 'test_123', + title: 'Test', + filePath: '/path', + timestamp: DateTime(2025, 1, 1, 12, 0), + duration: const Duration(seconds: 120), + tags: ['tag1'], + transcript: 'text', + fileSizeKB: 100, + ); + + final json = recording.toJson(); + + expect(json['id'], 'test_123'); + expect(json['title'], 'Test'); + expect(json['duration'], 120000); // milliseconds + expect(json['tags'], ['tag1']); + }); + + test('should create from JSON', () { + final json = { + 'id': 'test_123', + 'title': 'Test Recording', + 'filePath': '/path/to/file', + 'timestamp': '2025-01-01T12:00:00.000', + 'duration': 120000, + 'tags': ['tag1', 'tag2'], + 'transcript': 'Test text', + 'fileSizeKB': 512.0, + }; + + final recording = Recording.fromJson(json); + + expect(recording.id, 'test_123'); + expect(recording.title, 'Test Recording'); + expect(recording.duration, const Duration(seconds: 120)); + expect(recording.tags.length, 2); + }); + + test('should handle invalid JSON gracefully', () { + final json = { + 'id': null, + 'title': null, + 'filePath': null, + 'timestamp': 'invalid', + 'duration': null, + 'tags': null, + 'transcript': null, + 'fileSizeKB': null, + }; + + // Should throw assertion error because ID would be empty + expect(() => Recording.fromJson(json), throwsAssertionError); + }); + + test('should assert non-empty ID', () { + expect( + () => Recording( + id: '', + title: 'Test', + filePath: '/path', + timestamp: DateTime.now(), + duration: Duration.zero, + tags: [], + transcript: '', + fileSizeKB: 0, + ), + throwsAssertionError, + ); + }); + + test('should assert non-negative duration', () { + expect( + () => Recording( + id: 'test', + title: 'Test', + filePath: '/path', + timestamp: DateTime.now(), + duration: const Duration(seconds: -1), + tags: [], + transcript: '', + fileSizeKB: 0, + ), + throwsAssertionError, + ); + }); + }); +} diff --git a/test/repositories/recording_repository_test.dart b/test/repositories/recording_repository_test.dart new file mode 100644 index 0000000..350d255 --- /dev/null +++ b/test/repositories/recording_repository_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:parachute/models/recording.dart'; +import 'package:parachute/repositories/recording_repository.dart'; +import 'package:parachute/services/storage_service.dart'; + +void main() { + group('RecordingRepository', () { + late RecordingRepository repository; + late StorageService storageService; + + setUp(() { + storageService = StorageService(); + repository = RecordingRepository(storageService); + }); + + test('should create repository with storage service', () { + expect(repository, isNotNull); + }); + + test('should provide clean API for data access', () { + // Verify that repository exposes the expected methods + expect(repository.getAllRecordings, isA()); + expect(repository.getRecordingById, isA()); + expect(repository.saveRecording, isA()); + expect(repository.updateRecording, isA()); + expect(repository.deleteRecording, isA()); + expect(repository.clearAll, isA()); + }); + + // Note: Full integration tests would require mocking StorageService + // or using test fixtures. These would be added in a full test suite. + }); +} diff --git a/test/utils/validators_test.dart b/test/utils/validators_test.dart new file mode 100644 index 0000000..c01f088 --- /dev/null +++ b/test/utils/validators_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:parachute/utils/validators.dart'; + +void main() { + group('Validators', () { + group('validateTitle', () { + test('should return null for valid title', () { + expect(Validators.validateTitle('My Recording'), isNull); + expect(Validators.validateTitle('Test 123'), isNull); + }); + + test('should return error for empty title', () { + expect(Validators.validateTitle(''), isNotNull); + expect(Validators.validateTitle(' '), isNotNull); + expect(Validators.validateTitle(null), isNotNull); + }); + + test('should return error for title exceeding max length', () { + final longTitle = 'a' * 101; + expect(Validators.validateTitle(longTitle), isNotNull); + }); + + test('should accept title at max length', () { + final maxTitle = 'a' * 100; + expect(Validators.validateTitle(maxTitle), isNull); + }); + }); + + group('validateApiKey', () { + test('should return null for valid API key', () { + expect( + Validators.validateApiKey('sk-1234567890abcdefghijklmnop'), isNull); + }); + + test('should return error for empty key', () { + expect(Validators.validateApiKey(''), isNotNull); + expect(Validators.validateApiKey(' '), isNotNull); + expect(Validators.validateApiKey(null), isNotNull); + }); + + test('should return error for invalid format', () { + expect(Validators.validateApiKey('invalid-key'), isNotNull); + expect(Validators.validateApiKey('1234567890'), isNotNull); + }); + + test('should return error for too short key', () { + expect(Validators.validateApiKey('sk-123'), isNotNull); + }); + }); + + group('validateTag', () { + test('should return null for valid tag', () { + expect(Validators.validateTag('work'), isNull); + expect(Validators.validateTag('my-tag'), isNull); + expect(Validators.validateTag('tag 123'), isNull); + }); + + test('should return null for empty tag (optional)', () { + expect(Validators.validateTag(''), isNull); + expect(Validators.validateTag(null), isNull); + }); + + test('should return error for tag with special characters', () { + expect(Validators.validateTag('tag@special'), isNotNull); + expect(Validators.validateTag('tag#hash'), isNotNull); + }); + + test('should return error for tag exceeding max length', () { + final longTag = 'a' * 51; + expect(Validators.validateTag(longTag), isNotNull); + }); + }); + + group('sanitize', () { + test('should trim whitespace', () { + expect(Validators.sanitize(' test '), 'test'); + expect(Validators.sanitize('\n\ttest\n\t'), 'test'); + }); + + test('should not modify already trimmed text', () { + expect(Validators.sanitize('test'), 'test'); + }); + }); + + group('isValidFilePath', () { + test('should return true for valid paths', () { + expect(Validators.isValidFilePath('/path/to/file.m4a'), isTrue); + expect(Validators.isValidFilePath('C:\\path\\to\\file.m4a'), isTrue); + }); + + test('should return false for invalid paths', () { + expect(Validators.isValidFilePath(''), isFalse); + expect(Validators.isValidFilePath(null), isFalse); + expect(Validators.isValidFilePath('justfilename'), isFalse); + }); + }); + }); +}