diff --git a/CHANGELOG.md b/CHANGELOG.md index 397d33b..0a98f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.0 + +- Add `TimePickerComponent` + ## 0.1.0 - Add `CircleButtonComponent` diff --git a/example/lib/main.dart b/example/lib/main.dart index 1bba4ec..d45915a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -16,9 +16,14 @@ class FlameUIExample extends FlameGame with TapCallbacks, HasKeyboardHandlerComponents { late TextFieldComponent nameField; late ModalComponent modal; + int _selectedHour = 10; + int _selectedMinute = 30; + late RectangleButtonComponent _timePickerButton; @override Future onLoad() async { + ModalRouter.init(this); + final screenSize = size; final rootContainer = RectangleComponent( @@ -121,6 +126,17 @@ class FlameUIExample extends FlameGame rootContainer.add(toggle); + // Time Picker button + _timePickerButton = RectangleButtonComponent( + label: + '${_selectedHour.toString().padLeft(2, '0')}:${_selectedMinute.toString().padLeft(2, '0')}', + position: Vector2(130, 340), + size: Vector2(80, 28), + color: Colors.deepPurple, + onPressed: _showTimePicker, + ); + rootContainer.add(_timePickerButton); + // Wrap everything in scrollable area final scrollable = ScrollableAreaComponent( content: rootContainer, @@ -162,6 +178,38 @@ class FlameUIExample extends FlameGame add(modal); } + + Future _showTimePicker() async { + final timePicker = TimePickerComponent( + hour: _selectedHour, + minute: _selectedMinute, + onChanged: (value) { + _selectedHour = value.hour; + _selectedMinute = value.minute; + _timePickerButton.label = + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; + _timePickerButton.text.text = _timePickerButton.label; + }, + size: Vector2(120, 80), + position: Vector2(10, 0), + ); + + final sprite = Sprite(await images.load('cross.png')); + final timeModal = ModalComponent( + scrollContent: timePicker, + size: Vector2(160, 130), + position: size / 2 - Vector2(80, 65), + title: 'Pick Time', + trailing: SpriteButtonComponent( + button: sprite, + buttonDown: sprite, + size: Vector2.all(16), + onPressed: () => ModalRouter.pop(), + ), + ); + + ModalRouter.push(timeModal); + } } class _ToggleIndicator extends PositionComponent { diff --git a/example/pubspec.lock b/example/pubspec.lock index d1a5c11..8c9a886 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -31,7 +31,7 @@ packages: path: ".." relative: true source: path - version: "0.1.0" + version: "0.2.0" flutter: dependency: "direct main" description: flutter @@ -49,10 +49,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" ordered_set: dependency: transitive description: @@ -70,10 +70,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" sdks: - dart: ">=3.7.2 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.27.1" diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index dfe21cd..db2623d 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1,8 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flame_ui_example/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('Example app smoke test', (WidgetTester tester) async { + // Placeholder – Flame games require a custom test harness. + expect(1 + 1, 2); }); } diff --git a/lib/flame_ui.dart b/lib/flame_ui.dart index 6ccef76..0f43d47 100644 --- a/lib/flame_ui.dart +++ b/lib/flame_ui.dart @@ -1,5 +1,3 @@ -library flame_ui; - export 'inputs.dart'; export 'layout.dart'; export 'lists.dart'; diff --git a/lib/inputs.dart b/lib/inputs.dart index d7b71ca..d1df69a 100644 --- a/lib/inputs.dart +++ b/lib/inputs.dart @@ -1,2 +1,3 @@ export 'inputs/text_field_component.dart'; +export 'inputs/time_picker_component.dart'; export 'inputs/toggle_component.dart'; diff --git a/lib/inputs/time_picker_component.dart b/lib/inputs/time_picker_component.dart new file mode 100644 index 0000000..d637ee9 --- /dev/null +++ b/lib/inputs/time_picker_component.dart @@ -0,0 +1,253 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flutter/painting.dart'; + +/// A scrollable-wheel time picker with two columns (hours and minutes). +/// +/// Each column displays a vertical list of values that can be scrolled +/// by dragging. The center item is the selected value (highlighted). +/// Values snap to the nearest item on release. +class TimePickerComponent extends PositionComponent { + int _hour; + int _minute; + final int minuteStep; + final void Function(({int hour, int minute}) value) onChanged; + final TextStyle? style; + final TextStyle? selectedStyle; + + late final _ScrollColumn _hourColumn; + late final _ScrollColumn _minuteColumn; + + /// Creates a [TimePickerComponent]. + /// + /// [hour] and [minute] set the initial time. + /// [onChanged] is called whenever the selected time changes. + /// [minuteStep] controls the minute increment (default 15). + TimePickerComponent({ + required int hour, + required int minute, + required this.onChanged, + this.minuteStep = 15, + Vector2? size, + Vector2? position, + this.style, + this.selectedStyle, + }) : _hour = hour, + _minute = minute, + super( + size: size ?? Vector2(60, 36), + position: position ?? Vector2.zero(), + ); + + int get hour => _hour; + set hour(int value) { + _hour = value; + _hourColumn.selectedValue = value; + } + + int get minute => _minute; + set minute(int value) { + _minute = value; + _minuteColumn.selectedValue = value; + } + + @override + Future onLoad() async { + await super.onLoad(); + + final colonWidth = 10.0; + final columnWidth = (size.x - colonWidth) / 2; + final columnSize = Vector2(columnWidth, size.y); + + final hours = List.generate(24, (i) => i); + final minutes = List.generate(60 ~/ minuteStep, (i) => i * minuteStep); + + _hourColumn = _ScrollColumn( + values: hours, + initialValue: _hour, + onChanged: (v) { + _hour = v; + onChanged((hour: _hour, minute: _minute)); + }, + size: columnSize, + position: Vector2.zero(), + normalStyle: style, + selectedStyle: selectedStyle, + ); + + _minuteColumn = _ScrollColumn( + values: minutes, + initialValue: _minute, + onChanged: (v) { + _minute = v; + onChanged((hour: _hour, minute: _minute)); + }, + size: columnSize, + position: Vector2(columnWidth + colonWidth, 0), + normalStyle: style, + selectedStyle: selectedStyle, + ); + + addAll([_hourColumn, _minuteColumn]); + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + final colonPaint = TextPaint( + style: + selectedStyle ?? + const TextStyle(color: Color(0xFFFFFFFF), fontSize: 10), + ); + colonPaint.render( + canvas, + ':', + Vector2(size.x / 2, size.y / 2), + anchor: Anchor.center, + ); + } +} + +class _ScrollColumn extends PositionComponent with DragCallbacks { + final List values; + final void Function(int value) onChanged; + double _scrollOffset; + final double itemHeight; + double _velocity = 0; + int _lastNotifiedValue; + static const int _visibleCount = 3; + static const double _friction = 8.0; + static const double _snapStrength = 12.0; + + final TextPaint _normalPaint; + final TextPaint _selectedPaint; + + _ScrollColumn({ + required this.values, + required int initialValue, + required this.onChanged, + required Vector2 size, + required Vector2 position, + TextStyle? normalStyle, + TextStyle? selectedStyle, + }) : _lastNotifiedValue = initialValue, + itemHeight = size.y / _visibleCount, + _scrollOffset = + values.indexOf(initialValue).toDouble() * (size.y / _visibleCount), + _normalPaint = TextPaint( + style: + normalStyle ?? + const TextStyle(color: Color(0x88FFFFFF), fontSize: 8), + ), + _selectedPaint = TextPaint( + style: + selectedStyle ?? + const TextStyle(color: Color(0xFFFFFFFF), fontSize: 10), + ), + super(size: size, position: position); + + int get selectedValue { + final idx = + ((_nearestIndex() % values.length) + values.length) % values.length; + return values[idx]; + } + + set selectedValue(int value) { + final idx = values.indexOf(value); + if (idx >= 0) { + _scrollOffset = idx * itemHeight; + _velocity = 0; + _lastNotifiedValue = value; + } + } + + int _nearestIndex() => (_scrollOffset / itemHeight).round(); + + double get _totalHeight => values.length * itemHeight; + + double _wrapOffset(double offset) { + final total = _totalHeight; + return ((offset % total) + total) % total; + } + + void _notifyIfChanged() { + final current = selectedValue; + if (current != _lastNotifiedValue) { + _lastNotifiedValue = current; + onChanged(current); + } + } + + @override + bool onDragUpdate(DragUpdateEvent event) { + _velocity = 0; + _scrollOffset -= event.localDelta.y; + _scrollOffset = _wrapOffset(_scrollOffset); + return true; + } + + @override + bool onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + _velocity = -event.velocity.y; + return true; + } + + @override + void update(double dt) { + super.update(dt); + + if (_velocity.abs() > 1.0) { + _scrollOffset += _velocity * dt; + _scrollOffset = _wrapOffset(_scrollOffset); + _velocity *= (1 - _friction * dt).clamp(0.0, 1.0); + } else { + _velocity = 0; + // Snap to nearest value + final targetIndex = _nearestIndex(); + final target = _wrapOffset(targetIndex * itemHeight); + + double diff = target - _scrollOffset; + final total = _totalHeight; + if (diff > total / 2) diff -= total; + if (diff < -total / 2) diff += total; + + if (diff.abs() > 0.5) { + _scrollOffset += diff * _snapStrength * dt; + _scrollOffset = _wrapOffset(_scrollOffset); + } else if (diff.abs() > 0.01) { + _scrollOffset = target; + _notifyIfChanged(); + } + } + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + canvas.save(); + canvas.clipRect(Rect.fromLTWH(0, 0, size.x, size.y)); + + final centerY = size.y / 2; + final fractionalIndex = _scrollOffset / itemHeight; + final centerIdx = fractionalIndex.round(); + final pixelOffset = (fractionalIndex - centerIdx) * itemHeight; + + final halfVisible = _visibleCount ~/ 2; + + for (int i = -halfVisible; i <= halfVisible; i++) { + final valueIndex = + ((centerIdx + i) % values.length + values.length) % values.length; + final y = centerY + i * itemHeight - pixelOffset; + + final isCenter = i == 0; + final paint = isCenter ? _selectedPaint : _normalPaint; + final text = values[valueIndex].toString().padLeft(2, '0'); + paint.render(canvas, text, Vector2(size.x / 2, y), anchor: Anchor.center); + } + + canvas.restore(); + } +} diff --git a/lib/modals.dart b/lib/modals.dart index f6f474d..0d56660 100644 --- a/lib/modals.dart +++ b/lib/modals.dart @@ -1 +1,2 @@ export 'modals/modal_component.dart'; +export 'modals/modal_router.dart'; diff --git a/lib/modals/modal_router.dart b/lib/modals/modal_router.dart new file mode 100644 index 0000000..547cb1b --- /dev/null +++ b/lib/modals/modal_router.dart @@ -0,0 +1,45 @@ +import 'package:flame/game.dart'; + +import 'modal_component.dart'; + +class ModalRouter { + static final ModalRouter _instance = ModalRouter._(); + static ModalRouter get instance => _instance; + static bool get hasModals => instance._stack.isNotEmpty; + + late FlameGame _game; + final _stack = []; + + ModalRouter._(); + + static init(FlameGame game) { + instance._game = game; + } + + static void push(ModalComponent modal) { + instance._stack.add(modal); + modal.show(instance._game); + } + + static void pop() { + instance._stack.removeLast().hide(); + } + + static void popAll() { + while (instance._stack.isNotEmpty) { + pop(); + } + } + + static ModalComponent? get currentModal { + if (instance._stack.isEmpty) return null; + return instance._stack.last; + } + + static void replace(ModalComponent modal) { + if (instance._stack.isNotEmpty) { + pop(); + } + push(modal); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8978bfc..a913697 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flame_ui description: A reusable UI component library for Flame games, including buttons, text fields, modals, lists, and layout helpers. -version: 0.1.0 +version: 0.2.0 homepage: https://github.com/chenasraf/flame_ui repository: https://github.com/chenasraf/flame_ui diff --git a/test/flame_ui_test.dart b/test/flame_ui_test.dart index 33160c1..5cb7cff 100644 --- a/test/flame_ui_test.dart +++ b/test/flame_ui_test.dart @@ -1,12 +1,22 @@ +// Main test file - individual component tests are in subdirectories. +// Run all tests with: flutter test + import 'package:flutter_test/flutter_test.dart'; import 'package:flame_ui/flame_ui.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); + test('flame_ui barrel exports are accessible', () { + // Verify key classes are exported and accessible + expect(RectangleButtonComponent, isNotNull); + expect(CircleButtonComponent, isNotNull); + expect(ToggleComponent, isNotNull); + expect(TimePickerComponent, isNotNull); + expect(GridComponent, isNotNull); + expect(ScrollableAreaComponent, isNotNull); + expect(ListComponent, isNotNull); + expect(ListItemComponent, isNotNull); + expect(ModalComponent, isNotNull); + expect(ModalRouter, isNotNull); }); }