diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..ba712b4 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,30 @@ +name: Flutter CI + +on: + push: + branches: + - main + - feature/** + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: flutter test diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ce2a49b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `lib/` holds app code; features live under `lib/features//` with `view`, `viewmodel`, `widget`, and `models` folders following MVVM. Core-wide utilities will eventually sit under `lib/core/`. +- `test/` mirrors the feature layout (`test/features/voice_to_text/...`) for unit and widget coverage. Keep test helpers close to the code they exercise. +- Platform scaffolding resides in the standard `android/`, `ios/`, `web/`, and desktop directories. Treat these as generated unless platform work is assigned. + +## Build, Test, and Development Commands + +- `flutter pub get` — sync dependencies declared in `pubspec.yaml`. +- `flutter run` — launch the app using current configuration; defaults to `VoiceToTextScreen`. +- `flutter test` — execute all unit and widget tests; use `flutter test test/features/...` for targeted suites. + +## Coding Style & Naming Conventions + +- Follow Dart style: 2-space indentation, `lowerCamelCase` for variables/methods, `UpperCamelCase` for types, and `snake_case.dart` filenames. +- Widgets and view models live in dedicated files named after the class (`text_display.dart`, `voice_to_text_model.dart`). +- Rely on Flutter’s formatter: `dart format .` or the IDE’s auto-format on save. Static analysis is enforced via `analysis_options.yaml`; resolve its warnings before committing. + +## Testing Guidelines + +- Write unit tests for view models and pure logic; use `flutter_test` for widget layout/animation checks (see `waveform_test.dart`). +- Name tests descriptively using `group` + `testWidgets`/`test`; prefer arranging inputs/expectations inline for readability. +- Ensure new features extend the mirrored directory structure in `test/` and run `flutter test` before pushing. + +## Commit & Pull Request Guidelines + +- Use commits conventions fix, tests, chore, feat, build, refactor +- Commits in history use short, imperative messages (`Add waveform visualization`). Keep them scoped to a single concern and ensure formatting/lints pass (`flutter analyze` runs pre-commit via tooling). +- Pull requests should: + 1. Reference the corresponding GitHub issue in the description (`Fixes #2`). + 2. Summarize functional changes plus any architectural notes (e.g., new providers). + 3. Include screenshots or screen recordings for UI changes when feasible. + 4. Confirm tests ran successfully (`flutter test`) and note any manual verification performed. + +## Architecture Overview + +- The app is converging on MVVM: views consume `VoiceToTextModelState`/future providers, while view models expose immutable state and notify listeners. New features should respect this separation and favor dependency injection for services. + +## Tools + +- ALWAYS use `gh` cli when interacting with github + +## Code style + +- Always follow the guidelines layout [here](https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md) 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 3a21aa3..5be96b3 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 @@ -1,12 +1,17 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; abstract class VoiceToTextModel extends Listenable { List get transcript; // already tokenized words or segments int get activeWordIndex; bool get isCursorVisible; + List get waveformData; + Stream> get waveformStream; void setActiveWord(int index); void toggleCursorVisibility(bool visible); + void updateWaveform(List amplitudes); // Other state already planned (timer, waveform, recording commands) lives here too. } @@ -23,6 +28,9 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { final List _transcript; int _activeWordIndex; bool _isCursorVisible; + List _waveformData = const []; + final StreamController> _waveformController = + StreamController>.broadcast(); @override List get transcript => _transcript; @@ -33,6 +41,12 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { @override bool get isCursorVisible => _isCursorVisible; + @override + List get waveformData => _waveformData; + + @override + Stream> get waveformStream => _waveformController.stream; + @override void setActiveWord(int index) { if (index == _activeWordIndex) return; @@ -47,4 +61,19 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { _isCursorVisible = visible; notifyListeners(); } + + @override + void updateWaveform(List amplitudes) { + _waveformData = List.unmodifiable(amplitudes); + notifyListeners(); + if (!_waveformController.isClosed) { + _waveformController.add(_waveformData); + } + } + + @override + void dispose() { + _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 5b86ab5..75c89fe 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 @@ -1,7 +1,11 @@ +import 'dart:async'; +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../widget/text_display.dart'; +import '../widget/waveform.dart'; import 'voice_to_text_model.dart'; class VoiceToTextScreen extends StatelessWidget { @@ -35,20 +39,83 @@ class VoiceToTextScreen extends StatelessWidget { } } -class _VoiceToTextView extends StatelessWidget { +class _VoiceToTextView extends StatefulWidget { const _VoiceToTextView(); + @override + State<_VoiceToTextView> createState() => _VoiceToTextViewState(); +} + +class _VoiceToTextViewState extends State<_VoiceToTextView> { + static const int _waveformSampleCount = 48; + static const Duration _waveformTick = Duration(milliseconds: 100); + + final math.Random _random = math.Random(); + Timer? _waveformTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final model = context.read(); + _pushWaveformSample(model); + _waveformTimer = Timer.periodic( + _waveformTick, + (_) => _pushWaveformSample(model), + ); + }); + } + + @override + void dispose() { + _waveformTimer?.cancel(); + super.dispose(); + } + + void _pushWaveformSample(VoiceToTextModel model) { + final sample = List.generate(_waveformSampleCount, (index) { + final variance = math.sin(index / 4) * 0.3 + 0.5; + final noise = (_random.nextDouble() - 0.5) * 0.2; + return (variance + noise).clamp(0.0, 1.0); + }); + model.updateWaveform(sample); + } + @override Widget build(BuildContext context) { - final VoiceToTextModel model = context.watch(); + final VoiceToTextModelState model = context.watch(); return Scaffold( backgroundColor: const Color.fromARGB(255, 227, 227, 198), body: SafeArea( - child: TextDisplayWidget( - transcript: model.transcript, - activeWordIndex: model.activeWordIndex, - isCursorVisible: model.isCursorVisible, + child: Column( + children: [ + Expanded( + flex: 2, + child: TextDisplayWidget( + transcript: model.transcript, + activeWordIndex: model.activeWordIndex, + isCursorVisible: model.isCursorVisible, + ), + ), + const SizedBox(height: 16), + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: WaveformStream( + stream: model.waveformStream, + initialAmplitudes: model.waveformData, + height: 220, + barColor: Colors.black87, + barWidth: 3, + spacing: 6, + backgroundColor: Colors.transparent, + ), + ), + ), + const SizedBox(height: 24), + ], ), ), ); diff --git a/lib/features/voice_to_text/widget/waveform.dart b/lib/features/voice_to_text/widget/waveform.dart new file mode 100644 index 0000000..cfeac08 --- /dev/null +++ b/lib/features/voice_to_text/widget/waveform.dart @@ -0,0 +1,217 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class WaveformWidget extends StatefulWidget { + const WaveformWidget({ + super.key, + required this.amplitudes, + this.height = 220, + this.barColor = Colors.black, + this.barWidth = 3, + this.spacing = 6, + this.backgroundColor = Colors.transparent, + this.animationDuration = const Duration(milliseconds: 120), + }); + + final List amplitudes; + final double height; + final Color barColor; + final double barWidth; + final double spacing; + final Color backgroundColor; + final Duration animationDuration; + + @override + State createState() => _WaveformWidgetState(); +} + +class _WaveformWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late List _displayed; + late List _start; + late List _target; + + @override + void initState() { + super.initState(); + final normalized = WaveformPainter.normalize(widget.amplitudes); + _displayed = List.from(normalized); + _start = List.from(normalized); + _target = List.from(normalized); + _controller = AnimationController( + vsync: this, + duration: widget.animationDuration, + )..addListener(_handleTick); + } + + @override + void didUpdateWidget(covariant WaveformWidget oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.duration = widget.animationDuration; + final normalized = WaveformPainter.normalize(widget.amplitudes); + _start = _resizeList(_displayed, normalized.length); + _target = _resizeList(normalized, normalized.length); + if (_target.isEmpty) { + setState(() { + _displayed = const []; + }); + return; + } + _controller.forward(from: 0); + } + + @override + void dispose() { + _controller + ..removeListener(_handleTick) + ..dispose(); + super.dispose(); + } + + void _handleTick() { + final t = Curves.easeOut.transform(_controller.value); + final result = []; + final length = _target.length; + final current = _resizeList(_start, length); + for (var i = 0; i < length; i++) { + final interpolated = lerpDouble(current[i], _target[i], t) ?? _target[i]; + result.add(interpolated); + } + setState(() { + _displayed = result; + }); + } + + List _resizeList(List source, int length) { + if (length == 0) { + return const []; + } + if (source.length == length) { + return List.from(source); + } + final result = List.filled(length, 0); + for (var i = 0; i < length; i++) { + result[i] = i < source.length ? source[i] : 0; + } + return result; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: DecoratedBox( + decoration: BoxDecoration(color: widget.backgroundColor), + child: CustomPaint( + painter: WaveformPainter( + amplitudes: _displayed, + barColor: widget.barColor, + barWidth: widget.barWidth, + spacing: widget.spacing, + ), + ), + ), + ); + } +} + +class WaveformPainter extends CustomPainter { + const WaveformPainter({ + required this.amplitudes, + required this.barColor, + required this.barWidth, + required this.spacing, + }); + + final List amplitudes; + final Color barColor; + final double barWidth; + final double spacing; + + static List normalize(List values) { + return values + .map((value) { + final safeValue = value.isNaN ? 0.0 : value; + return safeValue.clamp(0.0, 1.0).toDouble(); + }) + .toList(growable: false); + } + + @override + void paint(Canvas canvas, Size size) { + if (amplitudes.isEmpty) { + return; + } + + final paint = Paint() + ..color = barColor + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + final totalWidth = + amplitudes.length * barWidth + (amplitudes.length - 1) * spacing; + final startX = (size.width - totalWidth) / 2; + final maxHeight = size.height; + + for (var i = 0; i < amplitudes.length; i++) { + final normalized = amplitudes[i].clamp(0.0, 1.0).toDouble(); + final barHeight = normalized * maxHeight; + final x = startX + i * (barWidth + spacing); + final top = (maxHeight - barHeight) / 2; + canvas.drawLine(Offset(x, top), Offset(x, top + barHeight), paint); + } + } + + @override + bool shouldRepaint(covariant WaveformPainter oldDelegate) { + return oldDelegate.amplitudes != amplitudes || + oldDelegate.barColor != barColor || + oldDelegate.barWidth != barWidth || + oldDelegate.spacing != spacing; + } +} + +class WaveformStream extends StatelessWidget { + const WaveformStream({ + super.key, + required this.stream, + required this.initialAmplitudes, + this.height = 220, + this.barColor = Colors.black, + this.barWidth = 3, + this.spacing = 6, + this.backgroundColor = Colors.transparent, + this.animationDuration = const Duration(milliseconds: 120), + }); + + final Stream> stream; + final List initialAmplitudes; + final double height; + final Color barColor; + final double barWidth; + final double spacing; + final Color backgroundColor; + final Duration animationDuration; + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: stream, + initialData: initialAmplitudes, + builder: (context, snapshot) { + final amplitudes = snapshot.data ?? const []; + return WaveformWidget( + amplitudes: amplitudes, + height: height, + barColor: barColor, + barWidth: barWidth, + spacing: spacing, + backgroundColor: backgroundColor, + animationDuration: animationDuration, + ); + }, + ); + } +} 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 9725495..c995e33 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 @@ -17,6 +17,7 @@ void main() { expect(() => model.transcript.add('extra'), throwsUnsupportedError); expect(model.activeWordIndex, 1); expect(model.isCursorVisible, isFalse); + expect(model.waveformData, isEmpty); }); test('updates active word and notifies listeners', () { @@ -62,5 +63,30 @@ void main() { expect(model.isCursorVisible, isTrue); expect(notifyCount, 2); }); + + test( + 'updateWaveform stores immutable data, notifies, and emits over stream', + () async { + final model = VoiceToTextModelState(initialTranscript: transcript); + final events = >[]; + final subscription = model.waveformStream.listen(events.add); + + var notifyCount = 0; + model.addListener(() => notifyCount++); + + model.updateWaveform([0.1, 0.5, 1.2]); + + expect(model.waveformData, [0.1, 0.5, 1.2]); + expect(() => model.waveformData.add(0.3), throwsUnsupportedError); + await Future.delayed(Duration.zero); + expect(events, [ + [0.1, 0.5, 1.2], + ]); + expect(notifyCount, 1); + + await subscription.cancel(); + model.dispose(); + }, + ); }); } diff --git a/test/features/voice_to_text/widget/waveform_test.dart b/test/features/voice_to_text/widget/waveform_test.dart new file mode 100644 index 0000000..79dcad7 --- /dev/null +++ b/test/features/voice_to_text/widget/waveform_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:iva_mobile/features/voice_to_text/widget/waveform.dart'; + +void main() { + group('WaveformPainter', () { + test('normalizes values into 0-1 range and handles NaN', () { + final result = WaveformPainter.normalize([1.5, -0.1, double.nan]); + expect(result, [1.0, 0.0, 0.0]); + }); + }); + + group('WaveformWidget', () { + testWidgets('renders CustomPaint with provided amplitudes', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: WaveformWidget(amplitudes: [0.2, 0.8])), + ), + ); + + final customPaintFinder = find.descendant( + of: find.byType(WaveformWidget), + matching: find.byWidgetPredicate( + (widget) => + widget is CustomPaint && widget.painter is WaveformPainter, + ), + ); + expect(customPaintFinder, findsOneWidget); + final customPaint = tester.widget(customPaintFinder); + final painter = customPaint.painter as WaveformPainter; + expect(painter.amplitudes, [0.2, 0.8]); + }); + + testWidgets('animates to updated amplitudes', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: _WaveformHost())), + ); + + final state = tester.state<_WaveformHostState>( + find.byType(_WaveformHost), + ); + state.update([0.9, 0.7]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final customPaintFinder = find.descendant( + of: find.byType(WaveformWidget), + matching: find.byWidgetPredicate( + (widget) => + widget is CustomPaint && widget.painter is WaveformPainter, + ), + ); + final painter = + tester.widget(customPaintFinder).painter + as WaveformPainter; + expect(painter.amplitudes, [0.9, 0.7]); + }); + }); +} + +class _WaveformHost extends StatefulWidget { + const _WaveformHost(); + + @override + State<_WaveformHost> createState() => _WaveformHostState(); +} + +class _WaveformHostState extends State<_WaveformHost> { + List _amplitudes = const [0.1, 0.3]; + + void update(List amplitudes) { + setState(() { + _amplitudes = amplitudes; + }); + } + + @override + Widget build(BuildContext context) { + return WaveformWidget( + amplitudes: _amplitudes, + animationDuration: const Duration(milliseconds: 40), + ); + } +}