From c44543741e000376ed3a14a92316738789aa0a10 Mon Sep 17 00:00:00 2001 From: Erick Date: Thu, 4 Jan 2024 09:44:30 -0300 Subject: [PATCH] feat: add NesRunningTextLines --- CHANGELOG.md | 2 + .../lib/gallery/sections/running_text.dart | 13 ++ lib/src/widgets/nes_running_text.dart | 6 + lib/src/widgets/nes_running_text_lines.dart | 118 ++++++++++++++++ lib/src/widgets/widgets.dart | 1 + .../widgets/nes_running_text_lines_test.dart | 129 ++++++++++++++++++ .../widgetbook/use_cases/running_texts.dart | 41 ++++++ .../widgetbook/widgetbook.directories.g.dart | 25 +++- 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 lib/src/widgets/nes_running_text_lines.dart create mode 100644 test/src/widgets/nes_running_text_lines_test.dart create mode 100644 widgetbook/lib/widgetbook/use_cases/running_texts.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b3673..5bbbe39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # 0.13.0 - feat: `NesContainer.paintBuilder` to override the one set on the theme. + - fix: `NesRunningText` was not disposing its controller. + - feat: add `NesRunningTextLines`. # 0.12.1 - fix: theme lerp causing error on null access. diff --git a/example/lib/gallery/sections/running_text.dart b/example/lib/gallery/sections/running_text.dart index 744eaa7..7f58507 100644 --- a/example/lib/gallery/sections/running_text.dart +++ b/example/lib/gallery/sections/running_text.dart @@ -18,6 +18,19 @@ class RunningTextSection extends StatelessWidget { const NesRunningText( text: 'This is a simple running text.', ), + const SizedBox(height: 32), + Text( + 'Running text multiline', + style: theme.textTheme.displayMedium, + ), + const SizedBox(height: 16), + const NesRunningTextLines( + texts: [ + 'Welcome back adventurer!', + 'Hope you gathered all your gear!', + 'The journey is about to begin!', + ], + ), ], ); } diff --git a/lib/src/widgets/nes_running_text.dart b/lib/src/widgets/nes_running_text.dart index 8adb36a..cd4822c 100644 --- a/lib/src/widgets/nes_running_text.dart +++ b/lib/src/widgets/nes_running_text.dart @@ -58,6 +58,12 @@ class _NesRunningTextState extends State } } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant NesRunningText oldWidget) { super.didUpdateWidget(oldWidget); diff --git a/lib/src/widgets/nes_running_text_lines.dart b/lib/src/widgets/nes_running_text_lines.dart new file mode 100644 index 0000000..f92e4e3 --- /dev/null +++ b/lib/src/widgets/nes_running_text_lines.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:nes_ui/nes_ui.dart'; + +/// {@template nes_running_text_lines} +/// A widget that displays running text, one line after the other. +/// {@endtemplate} +class NesRunningTextLines extends StatefulWidget { + /// {@macro nes_running_text_lines} + const NesRunningTextLines({ + required this.texts, + this.speed = .08, + this.textStyle, + this.onEnd, + this.running = true, + this.linesAlignment = MainAxisAlignment.start, + super.key, + }); + + /// The texts to display. + final List texts; + + /// The speed of the text, in seconds. + final double speed; + + /// The style of the text. When omitted, it uses the theme's bodyMedium style. + final TextStyle? textStyle; + + /// Called when the text has reached the end. + final VoidCallback? onEnd; + + /// Whether the text is running. + final bool running; + + /// The alignment of the lines. + final MainAxisAlignment linesAlignment; + + @override + State createState() => _NesRunningTextLinesState(); +} + +class _NesRunningTextLinesState extends State { + late Map _lines; + + @override + void initState() { + super.initState(); + + _initLines(); + + if (widget.running) { + _start(); + } + } + + @override + void didUpdateWidget(NesRunningTextLines oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.running != oldWidget.running) { + if (widget.running) { + _start(); + } + } + + if (widget.texts != oldWidget.texts) { + _initLines(); + if (widget.running) { + _start(); + } + } + } + + void _start() { + setState(() { + _lines[0] = widget.texts[0]; + }); + } + + void _initLines() { + _lines = { + for (var i = 0; i < widget.texts.length; i++) i: '', + }; + } + + void _next(int index) { + if (index < widget.texts.length - 1) { + setState(() { + _lines[index + 1] = widget.texts[index + 1]; + }); + } else { + widget.onEnd?.call(); + } + } + + @override + Widget build(BuildContext context) { + final texts = _lines.values; + return Column( + mainAxisAlignment: widget.linesAlignment, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < texts.length; i++) + if (_lines[i]?.isNotEmpty ?? false) + NesRunningText( + text: _lines[i] ?? '', + speed: widget.speed, + textStyle: widget.textStyle, + onEnd: () { + _next(i); + }, + running: widget.running, + ) + else + const Text(''), + ], + ); + } +} diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart index f94f3d0..277b9fa 100644 --- a/lib/src/widgets/widgets.dart +++ b/lib/src/widgets/widgets.dart @@ -16,6 +16,7 @@ export 'nes_key_icon.dart'; export 'nes_loading_indicator.dart'; export 'nes_pressabled.dart'; export 'nes_running_text.dart'; +export 'nes_running_text_lines.dart'; export 'nes_scrollbar.dart'; export 'nes_selection_list.dart'; export 'nes_single_child_scroll_view.dart'; diff --git a/test/src/widgets/nes_running_text_lines_test.dart b/test/src/widgets/nes_running_text_lines_test.dart new file mode 100644 index 0000000..03ccacf --- /dev/null +++ b/test/src/widgets/nes_running_text_lines_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nes_ui/nes_ui.dart'; + +extension PumpNessRunningTextLines on WidgetTester { + Future pumpRunningTextLines({ + required List texts, + bool running = true, + VoidCallback? onEnd, + Widget? child, + }) async { + return pumpWidget( + MaterialApp( + theme: flutterNesTheme(), + home: NesRunningTextLines( + texts: texts, + running: running, + onEnd: onEnd, + ), + ), + ); + } +} + +void main() { + group('NesButton', () { + testWidgets('renders', (tester) async { + await tester.pumpRunningTextLines( + texts: ['The Child', 'The Mandalorian'], + ); + + await tester.pumpAndSettle(); + expect(find.text('The Child'), findsOneWidget); + expect(find.text('The Mandalorian'), findsOneWidget); + }); + + testWidgets('calls onEnd when finishes', (tester) async { + var called = false; + await tester.pumpRunningTextLines( + texts: ['The Child', 'The Mandalorian'], + onEnd: () => called = true, + ); + + await tester.pumpAndSettle(); + expect(find.text('The Child'), findsOneWidget); + expect(find.text('The Mandalorian'), findsOneWidget); + expect(called, isTrue); + }); + + testWidgets('does not starts if playing is false', (tester) async { + await tester.pumpRunningTextLines( + texts: ['The Child', 'The Mandalorian'], + running: false, + ); + + await tester.pumpAndSettle(); + expect(find.text('The Child'), findsNothing); + }); + + testWidgets('plays the text once running changes to true', (tester) async { + var running = false; + await tester.pumpWidget( + MaterialApp( + theme: flutterNesTheme(), + home: StatefulBuilder( + builder: (context, setState) { + return Column( + children: [ + NesRunningTextLines( + texts: const ['The Child', 'The Mandalorian'], + running: running, + ), + ElevatedButton( + onPressed: () => setState(() => running = true), + child: const Text('Start'), + ), + ], + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('The Child'), findsNothing); + expect(find.text('The Mandalorian'), findsNothing); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text('The Child'), findsOneWidget); + expect(find.text('The Mandalorian'), findsOneWidget); + }); + + testWidgets('plays the new text when received a new one', (tester) async { + var texts = ['The Child', 'The Mandalorian']; + await tester.pumpWidget( + MaterialApp( + theme: flutterNesTheme(), + home: StatefulBuilder( + builder: (context, setState) { + return Column( + children: [ + NesRunningTextLines( + texts: texts, + ), + ElevatedButton( + onPressed: () => setState( + () => texts = ['Ramona', 'Salazar'], + ), + child: const Text('Start'), + ), + ], + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('The Child'), findsOneWidget); + expect(find.text('The Mandalorian'), findsOneWidget); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text('Ramona'), findsOneWidget); + expect(find.text('Salazar'), findsOneWidget); + }); + }); +} diff --git a/widgetbook/lib/widgetbook/use_cases/running_texts.dart b/widgetbook/lib/widgetbook/use_cases/running_texts.dart new file mode 100644 index 0000000..7f48c24 --- /dev/null +++ b/widgetbook/lib/widgetbook/use_cases/running_texts.dart @@ -0,0 +1,41 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flutter/material.dart'; +import 'package:nes_ui/nes_ui.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +@widgetbook.UseCase( + name: 'default', + type: NesRunningText, +) +Widget normal(BuildContext context) { + return const Center( + child: NesContainer( + width: 400, + height: 200, + child: NesRunningText( + text: 'Get ready!', + ), + ), + ); +} + +@widgetbook.UseCase( + name: 'default', + type: NesRunningTextLines, +) +Widget lines(BuildContext context) { + return const Center( + child: NesContainer( + width: 600, + height: 160, + child: NesRunningTextLines( + texts: [ + 'Welcome back adventurer!', + 'Hope you gathered all your gear!', + 'The journey is about to begin!', + ], + ), + ), + ); +} diff --git a/widgetbook/lib/widgetbook/widgetbook.directories.g.dart b/widgetbook/lib/widgetbook/widgetbook.directories.g.dart index 1a943de..fe4dbd9 100644 --- a/widgetbook/lib/widgetbook/widgetbook.directories.g.dart +++ b/widgetbook/lib/widgetbook/widgetbook.directories.g.dart @@ -12,7 +12,8 @@ import 'package:widgetbook/widgetbook.dart' as _i1; import 'package:widgetbook_app/widgetbook/use_cases/buttons.dart' as _i2; import 'package:widgetbook_app/widgetbook/use_cases/checkboxes.dart' as _i3; -import 'package:widgetbook_app/widgetbook/use_cases/containers.dart' as _i4; +import 'package:widgetbook_app/widgetbook/use_cases/containers.dart' as _i5; +import 'package:widgetbook_app/widgetbook/use_cases/running_texts.dart' as _i4; final directories = <_i1.WidgetbookNode>[ _i1.WidgetbookFolder( @@ -50,6 +51,20 @@ final directories = <_i1.WidgetbookNode>[ builder: _i3.checkbox, ), ), + _i1.WidgetbookLeafComponent( + name: 'NesRunningText', + useCase: _i1.WidgetbookUseCase( + name: 'default', + builder: _i4.normal, + ), + ), + _i1.WidgetbookLeafComponent( + name: 'NesRunningTextLines', + useCase: _i1.WidgetbookUseCase( + name: 'default', + builder: _i4.lines, + ), + ), _i1.WidgetbookFolder( name: 'containers', children: [ @@ -58,19 +73,19 @@ final directories = <_i1.WidgetbookNode>[ useCases: [ _i1.WidgetbookUseCase( name: 'default', - builder: _i4.normal, + builder: _i5.normal, ), _i1.WidgetbookUseCase( name: 'with corner inner square painter', - builder: _i4.cornerInnerSquare, + builder: _i5.cornerInnerSquare, ), _i1.WidgetbookUseCase( name: 'with label', - builder: _i4.label, + builder: _i5.label, ), _i1.WidgetbookUseCase( name: 'with square corner painter', - builder: _i4.squareConer, + builder: _i5.squareConer, ), ], )