Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release

# Devtools
devtools_options.yaml
50 changes: 50 additions & 0 deletions lib/features/voice_to_text/view/voice_to_text_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ abstract class VoiceToTextModel extends Listenable {
bool get isCursorVisible;
List<double> get waveformData;
Stream<List<double>> get waveformStream;
Duration get elapsedDuration;
bool get isTimerRunning;

void setActiveWord(int index);
void toggleCursorVisibility(bool visible);
void updateWaveform(List<double> amplitudes);
void startTimer();
void pauseTimer();
void resetTimer();

// Other state already planned (timer, waveform, recording commands) lives here too.
}
Expand All @@ -31,6 +36,9 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel {
List<double> _waveformData = const [];
final StreamController<List<double>> _waveformController =
StreamController<List<double>>.broadcast();
Duration _elapsedDuration = Duration.zero;
bool _isTimerRunning = false;
Timer? _timer;

@override
List<String> get transcript => _transcript;
Expand All @@ -47,6 +55,12 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel {
@override
Stream<List<double>> get waveformStream => _waveformController.stream;

@override
Duration get elapsedDuration => _elapsedDuration;

@override
bool get isTimerRunning => _isTimerRunning;

@override
void setActiveWord(int index) {
if (index == _activeWordIndex) return;
Expand All @@ -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();
}
Expand Down
3 changes: 3 additions & 0 deletions lib/features/voice_to_text/view/voice_to_text_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,6 +64,7 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> {
_waveformTick,
(_) => _pushWaveformSample(model),
);
model.startTimer();
});
}

Expand Down Expand Up @@ -114,6 +116,7 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> {
),
),
),
TimerDisplay(duration: model.elapsedDuration),
const SizedBox(height: 24),
],
),
Expand Down
39 changes: 39 additions & 0 deletions lib/features/voice_to_text/widget/timer_display.dart
Original file line number Diff line number Diff line change
@@ -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()],
Comment on lines +1 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] Import FontFeature to compile TimerDisplay

The new TimerDisplay widget calls FontFeature.tabularFigures() but the file only imports package:flutter/material.dart. FontFeature lives in dart:ui and is not re-exported by the Material barrel, so the widget fails to compile with Undefined class 'FontFeature'. Add an explicit dart:ui import (or prefix the symbol) so the timer can build.

Useful? React with 👍 / 👎.

);

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';
}
}
2 changes: 1 addition & 1 deletion pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ packages:
source: hosted
version: "6.0.0"
fake_async:
dependency: transitive
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions test/features/voice_to_text/view/voice_to_text_model_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
});
}
18 changes: 18 additions & 0 deletions test/features/voice_to_text/widget/timer_display_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}