mirror of
https://github.com/chenasraf/flame_ui.git
synced 2026-05-17 17:38:07 +00:00
feat: add TimePickerComponent
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
## 0.2.0
|
||||
|
||||
- Add `TimePickerComponent`
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Add `CircleButtonComponent`
|
||||
|
||||
@@ -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<void> 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<void> _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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
library flame_ui;
|
||||
|
||||
export 'inputs.dart';
|
||||
export 'layout.dart';
|
||||
export 'lists.dart';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'inputs/text_field_component.dart';
|
||||
export 'inputs/time_picker_component.dart';
|
||||
export 'inputs/toggle_component.dart';
|
||||
|
||||
253
lib/inputs/time_picker_component.dart
Normal file
253
lib/inputs/time_picker_component.dart
Normal file
@@ -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<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
|
||||
final colonWidth = 10.0;
|
||||
final columnWidth = (size.x - colonWidth) / 2;
|
||||
final columnSize = Vector2(columnWidth, size.y);
|
||||
|
||||
final hours = List<int>.generate(24, (i) => i);
|
||||
final minutes = List<int>.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<int> 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();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export 'modals/modal_component.dart';
|
||||
export 'modals/modal_router.dart';
|
||||
|
||||
45
lib/modals/modal_router.dart
Normal file
45
lib/modals/modal_router.dart
Normal file
@@ -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 = <ModalComponent>[];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user