diff --git a/.gitignore b/.gitignore index 3820a95..666b6b0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Devtools +devtools_options.yaml \ No newline at end of file diff --git a/lib/features/voice_to_text/view/voice_to_text_model.dart b/lib/features/voice_to_text/view/voice_to_text_model.dart index 5be96b3..04ba44c 100644 --- a/lib/features/voice_to_text/view/voice_to_text_model.dart +++ b/lib/features/voice_to_text/view/voice_to_text_model.dart @@ -8,10 +8,15 @@ abstract class VoiceToTextModel extends Listenable { bool get isCursorVisible; List get waveformData; Stream> get waveformStream; + Duration get elapsedDuration; + bool get isTimerRunning; void setActiveWord(int index); void toggleCursorVisibility(bool visible); void updateWaveform(List amplitudes); + void startTimer(); + void pauseTimer(); + void resetTimer(); // Other state already planned (timer, waveform, recording commands) lives here too. } @@ -31,6 +36,9 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { List _waveformData = const []; final StreamController> _waveformController = StreamController>.broadcast(); + Duration _elapsedDuration = Duration.zero; + bool _isTimerRunning = false; + Timer? _timer; @override List get transcript => _transcript; @@ -47,6 +55,12 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { @override Stream> get waveformStream => _waveformController.stream; + @override + Duration get elapsedDuration => _elapsedDuration; + + @override + bool get isTimerRunning => _isTimerRunning; + @override void setActiveWord(int index) { if (index == _activeWordIndex) return; @@ -71,8 +85,44 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { } } + @override + void startTimer() { + if (_isTimerRunning) return; + _isTimerRunning = true; + _timer ??= Timer.periodic(const Duration(seconds: 1), (_) { + _elapsedDuration += const Duration(seconds: 1); + notifyListeners(); + }); + notifyListeners(); + } + + @override + void pauseTimer() { + if (!_isTimerRunning && _timer == null) return; + _isTimerRunning = false; + _timer?.cancel(); + _timer = null; + notifyListeners(); + } + + @override + void resetTimer() { + final hadElapsed = _elapsedDuration != Duration.zero; + final wasRunning = _isTimerRunning || _timer != null; + _elapsedDuration = Duration.zero; + if (wasRunning) { + _timer?.cancel(); + _timer = null; + _isTimerRunning = false; + } + if (hadElapsed || wasRunning) { + notifyListeners(); + } + } + @override void dispose() { + _timer?.cancel(); _waveformController.close(); super.dispose(); } diff --git a/lib/features/voice_to_text/view/voice_to_text_screen.dart b/lib/features/voice_to_text/view/voice_to_text_screen.dart index 75c89fe..b5c4ab6 100644 --- a/lib/features/voice_to_text/view/voice_to_text_screen.dart +++ b/lib/features/voice_to_text/view/voice_to_text_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../widget/text_display.dart'; +import '../widget/timer_display.dart'; import '../widget/waveform.dart'; import 'voice_to_text_model.dart'; @@ -63,6 +64,7 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> { _waveformTick, (_) => _pushWaveformSample(model), ); + model.startTimer(); }); } @@ -114,6 +116,7 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> { ), ), ), + TimerDisplay(duration: model.elapsedDuration), const SizedBox(height: 24), ], ), diff --git a/lib/features/voice_to_text/widget/timer_display.dart b/lib/features/voice_to_text/widget/timer_display.dart new file mode 100644 index 0000000..085bc50 --- /dev/null +++ b/lib/features/voice_to_text/widget/timer_display.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class TimerDisplay extends StatelessWidget { + const TimerDisplay({super.key, required this.duration}); + + final Duration duration; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final style = + textTheme.titleMedium?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + letterSpacing: 1.2, + fontFeatures: const [FontFeature.tabularFigures()], + ) ?? + const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Color(0xFF757575), + letterSpacing: 1.2, + fontFeatures: [FontFeature.tabularFigures()], + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text(_format(duration), style: style), + ); + } + + String _format(Duration duration) { + String twoDigits(int value) => value.toString().padLeft(2, '0'); + final minutes = twoDigits(duration.inMinutes.remainder(60)); + final seconds = twoDigits(duration.inSeconds.remainder(60)); + return '$minutes:$seconds'; + } +} diff --git a/pubspec.lock b/pubspec.lock index d66db9a..1956a6a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -114,7 +114,7 @@ packages: source: hosted version: "6.0.0" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index 9b1959a..3271841 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + fake_async: ^1.3.3 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/features/voice_to_text/view/voice_to_text_model_test.dart b/test/features/voice_to_text/view/voice_to_text_model_test.dart index c995e33..9416ad3 100644 --- a/test/features/voice_to_text/view/voice_to_text_model_test.dart +++ b/test/features/voice_to_text/view/voice_to_text_model_test.dart @@ -1,3 +1,4 @@ +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:iva_mobile/features/voice_to_text/view/voice_to_text_model.dart'; @@ -88,5 +89,48 @@ void main() { model.dispose(); }, ); + + test('timer increments while running and pauses correctly', () { + fakeAsync((async) { + final model = VoiceToTextModelState(initialTranscript: transcript); + + model.startTimer(); + expect(model.isTimerRunning, isTrue); + + async.elapse(const Duration(seconds: 2)); + expect(model.elapsedDuration, const Duration(seconds: 2)); + + model.pauseTimer(); + expect(model.isTimerRunning, isFalse); + + async.elapse(const Duration(seconds: 3)); + expect(model.elapsedDuration, const Duration(seconds: 2)); + + model.startTimer(); + async.elapse(const Duration(seconds: 1)); + expect(model.elapsedDuration, const Duration(seconds: 3)); + + model.dispose(); + }); + }); + + test('resetTimer clears elapsed duration and stops the timer', () { + fakeAsync((async) { + final model = VoiceToTextModelState(initialTranscript: transcript); + + model.startTimer(); + async.elapse(const Duration(seconds: 5)); + expect(model.elapsedDuration, const Duration(seconds: 5)); + + model.resetTimer(); + expect(model.elapsedDuration, Duration.zero); + expect(model.isTimerRunning, isFalse); + + async.elapse(const Duration(seconds: 2)); + expect(model.elapsedDuration, Duration.zero); + + model.dispose(); + }); + }); }); } diff --git a/test/features/voice_to_text/widget/timer_display_test.dart b/test/features/voice_to_text/widget/timer_display_test.dart new file mode 100644 index 0000000..fb73b8b --- /dev/null +++ b/test/features/voice_to_text/widget/timer_display_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:iva_mobile/features/voice_to_text/widget/timer_display.dart'; + +void main() { + testWidgets('TimerDisplay formats duration as MM:SS', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: TimerDisplay(duration: Duration(minutes: 5, seconds: 9)), + ), + ), + ); + + expect(find.text('05:09'), findsOneWidget); + }); +}