diff --git a/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart b/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart index 56ab1f4f3..7cb7c838d 100644 --- a/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart +++ b/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart @@ -18,7 +18,14 @@ final _schema = S.object( ), 'enableDate': S.boolean(), 'enableTime': S.boolean(), - 'outputFormat': S.string(), + 'firstDate': S.string( + description: + 'The earliest selectable date (YYYY-MM-DD). Defaults to -9999-01-01.', + ), + 'lastDate': S.string( + description: + 'The latest selectable date (YYYY-MM-DD). Defaults to 9999-12-31.', + ), }, required: ['value'], ); @@ -28,18 +35,24 @@ extension type _DateTimeInputData.fromMap(JsonMap _json) { required JsonMap value, bool? enableDate, bool? enableTime, - String? outputFormat, + String? firstDate, + String? lastDate, }) => _DateTimeInputData.fromMap({ 'value': value, 'enableDate': enableDate, 'enableTime': enableTime, - 'outputFormat': outputFormat, + 'firstDate': firstDate, + 'lastDate': lastDate, }); JsonMap get value => _json['value'] as JsonMap; bool get enableDate => (_json['enableDate'] as bool?) ?? true; bool get enableTime => (_json['enableTime'] as bool?) ?? true; - String? get outputFormat => _json['outputFormat'] as String?; + DateTime get firstDate => + DateTime.tryParse(_json['firstDate'] as String? ?? '') ?? DateTime(-9999); + DateTime get lastDate => + DateTime.tryParse(_json['lastDate'] as String? ?? '') ?? + DateTime(9999, 12, 31); } /// A catalog item representing a Material Design date and/or time input field. @@ -69,40 +82,24 @@ final dateTimeInput = CatalogItem( return ValueListenableBuilder( valueListenable: valueNotifier, builder: (context, value, child) { + final MaterialLocalizations localizations = MaterialLocalizations.of( + context, + ); + final String displayText = _getDisplayText( + value, + dateTimeInputData, + localizations, + ); + return ListTile( - title: Text(value ?? 'Select a date/time'), - onTap: () async { - final path = dateTimeInputData.value['path'] as String?; - if (path == null) { - return; - } - if (dateTimeInputData.enableDate) { - final DateTime? date = await showDatePicker( - context: itemContext.buildContext, - initialDate: DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), - ); - if (date != null) { - itemContext.dataContext.update( - DataPath(path), - date.toIso8601String(), - ); - } - } - if (dateTimeInputData.enableTime) { - final TimeOfDay? time = await showTimePicker( - context: itemContext.buildContext, - initialTime: TimeOfDay.now(), - ); - if (time != null) { - itemContext.dataContext.update( - DataPath(path), - time.format(itemContext.buildContext), - ); - } - } - }, + key: Key(itemContext.id), + title: Text(displayText, key: Key('${itemContext.id}_text')), + onTap: () => _handleTap( + context: itemContext.buildContext, + dataContext: itemContext.dataContext, + data: dateTimeInputData, + value: value, + ), ); }, ); @@ -122,5 +119,140 @@ final dateTimeInput = CatalogItem( } ] ''', + () => ''' + [ + { + "id": "root", + "component": { + "DateTimeInput": { + "value": { + "path": "/myDate" + }, + "enableTime": false + } + } + } + ] + ''', + () => ''' + [ + { + "id": "root", + "component": { + "DateTimeInput": { + "value": { + "path": "/myTime" + }, + "enableDate": false + } + } + } + ] + ''', ], ); + +Future _handleTap({ + required BuildContext context, + required DataContext dataContext, + required _DateTimeInputData data, + required String? value, +}) async { + final path = data.value['path'] as String?; + if (path == null) { + return; + } + + final DateTime initialDate = + DateTime.tryParse(value ?? '') ?? + DateTime.tryParse('1970-01-01T$value') ?? + DateTime.now(); + + var resultDate = initialDate; + var resultTime = TimeOfDay.fromDateTime(initialDate); + + if (data.enableDate) { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: data.firstDate, + lastDate: data.lastDate, + ); + if (pickedDate == null) return; // User cancelled. + resultDate = pickedDate; + } + + if (data.enableTime) { + final TimeOfDay? pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initialDate), + ); + if (pickedTime == null) return; // User cancelled. + resultTime = pickedTime; + } + + final finalDateTime = DateTime( + resultDate.year, + resultDate.month, + resultDate.day, + data.enableTime ? resultTime.hour : 0, + data.enableTime ? resultTime.minute : 0, + ); + + String formattedValue; + + if (data.enableDate && !data.enableTime) { + formattedValue = finalDateTime.toIso8601String().split('T').first; + } else if (!data.enableDate && data.enableTime) { + final String hour = finalDateTime.hour.toString().padLeft(2, '0'); + final String minute = finalDateTime.minute.toString().padLeft(2, '0'); + formattedValue = '$hour:$minute:00'; + } else { + // Both enabled (or both disabled, which shouldn't happen), + // write full ISO string. + formattedValue = finalDateTime.toIso8601String(); + } + + dataContext.update(DataPath(path), formattedValue); +} + +String _getDisplayText( + String? value, + _DateTimeInputData data, + MaterialLocalizations localizations, +) { + String getPlaceholderText() { + if (data.enableDate && data.enableTime) { + return 'Select a date and time'; + } else if (data.enableDate) { + return 'Select a date'; + } else if (data.enableTime) { + return 'Select a time'; + } + return 'Select a date/time'; + } + + DateTime? tryParseDateOrTime(String value) { + return DateTime.tryParse(value) ?? DateTime.tryParse('1970-01-01T$value'); + } + + String formatDateTime(DateTime date) { + final List parts = [ + if (data.enableDate) localizations.formatFullDate(date), + if (data.enableTime) + localizations.formatTimeOfDay(TimeOfDay.fromDateTime(date)), + ]; + return parts.join(' '); + } + + if (value == null) { + return getPlaceholderText(); + } + + final DateTime? date = tryParseDateOrTime(value); + if (date == null) { + return value; + } + + return formatDateTime(date); +} diff --git a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart index 3bf2bc8a6..2cfe0fd08 100644 --- a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart +++ b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart @@ -7,42 +7,219 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; void main() { - testWidgets('DateTimeInput widget renders and handles changes', ( - WidgetTester tester, - ) async { - final manager = A2uiMessageProcessor( - catalogs: [ - Catalog([ - CoreCatalogItems.dateTimeInput, - CoreCatalogItems.text, - ], catalogId: 'test_catalog'), - ], - ); - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'datetime', - componentProperties: { - 'DateTimeInput': { - 'value': {'path': '/myDateTime'}, - }, - }, - ), - ]; - manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - manager.handleMessage( - const BeginRendering( - surfaceId: surfaceId, - root: 'datetime', - catalogId: 'test_catalog', - ), - ); + testWidgets('renders and handles explicit updates', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('datetime', { + 'value': {'path': '/myDateTime'}, + 'enableTime': false, + }); + manager .dataModelForSurface(surfaceId) .update(DataPath('/myDateTime'), '2025-10-15'); + await robot.pumpSurface(manager, surfaceId); + + robot.expectInputText('datetime', 'Wednesday, October 15, 2025'); + }); + + testWidgets('displays correct placeholder/initial text based on mode', ( + tester, + ) async { + final robot = DateTimeInputRobot(tester); + + var (GenUiHost manager, String surfaceId) = setup('datetime_default', { + 'value': {'path': '/myDateTimeDefault'}, + }); + await robot.pumpSurface(manager, surfaceId); + robot.expectInputText('datetime_default', 'Select a date and time'); + + (manager, surfaceId) = setup('datetime_date_only', { + 'value': {'path': '/myDateOnly'}, + 'enableTime': false, + }); + await robot.pumpSurface(manager, surfaceId); + robot.expectInputText('datetime_date_only', 'Select a date'); + + (manager, surfaceId) = setup('datetime_time_only', { + 'value': {'path': '/myTimeOnly'}, + 'enableDate': false, + }); + await robot.pumpSurface(manager, surfaceId); + robot.expectInputText('datetime_time_only', 'Select a time'); + }); + + group('combined mode', () { + testWidgets('aborts update when time picker is cancelled', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('combined_mode', { + 'value': {'path': '/myDateTime'}, + }); + + manager + .dataModelForSurface(surfaceId) + .update(DataPath('/myDateTime'), '2022-01-01T14:30:00'); + + await robot.pumpSurface(manager, surfaceId); + + await robot.openPicker('combined_mode'); + await robot.selectDate('15'); + + robot.expectTimePickerVisible(); + await robot.cancelPicker(); + + final String? value = manager + .dataModelForSurface(surfaceId) + .getValue(DataPath('/myDateTime')); + expect(value, equals('2022-01-01T14:30:00')); + }); + }); + + group('time only mode', () { + testWidgets('aborts when time picker is cancelled', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('time_only_mode', { + 'value': {'path': '/myTime'}, + 'enableDate': false, + }); + + await robot.pumpSurface(manager, surfaceId); + + await robot.openPicker('time_only_mode'); + robot.expectTimePickerVisible(); + await robot.cancelPicker(); + + final String? value = manager + .dataModelForSurface(surfaceId) + .getValue(DataPath('/myTime')); + expect(value, isNull); + }); + + testWidgets('parses initial value correctly', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('time_only_parsing', { + 'value': {'path': '/myTimeProp'}, + 'enableDate': false, + }); + + manager + .dataModelForSurface(surfaceId) + .update(DataPath('/myTimeProp'), '14:32:00'); + + await robot.pumpSurface(manager, surfaceId); + + await robot.openPicker('time_only_parsing'); + + robot.expectPickerText('32'); + + await robot.cancelPicker(); + }); + }); + + group('date only mode', () { + testWidgets('updates immediately with date-only string after ' + 'date selection', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('date_only_mode', { + 'value': {'path': '/myDate'}, + 'enableTime': false, + }); + + manager + .dataModelForSurface(surfaceId) + .update(DataPath('/myDate'), '2022-01-01'); + + await robot.pumpSurface(manager, surfaceId); + + await robot.openPicker('date_only_mode'); + await robot.selectDate('20'); + + final String? value = manager + .dataModelForSurface(surfaceId) + .getValue(DataPath('/myDate')); + expect(value, isNotNull); + // Verify that no time is included in the value. + expect(value, equals('2022-01-20')); + robot.expectInputText('date_only_mode', 'Thursday, January 20, 2022'); + + robot.expectTimePickerHidden(); + }); + }); + + group('date range configuration', () { + testWidgets('respects custom firstDate and lastDate', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('custom_range', { + 'value': {'path': '/myDate'}, + 'firstDate': '2020-01-01', + 'lastDate': '2030-12-31', + }); + + await robot.pumpSurface(manager, surfaceId); + + await robot.openPicker('custom_range'); + + final DatePickerDialog dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.firstDate, DateTime(2020)); + expect(dialog.lastDate, DateTime(2030, 12, 31)); + + await robot.cancelPicker(); + }); + + testWidgets('defaults to -9999 to 9999 when not specified', (tester) async { + final robot = DateTimeInputRobot(tester); + final (GenUiHost manager, String surfaceId) = setup('default_range', { + 'value': {'path': '/myDate'}, + }); + + await robot.pumpSurface(manager, surfaceId); + await robot.openPicker('default_range'); + + final DatePickerDialog dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.firstDate, DateTime(-9999)); + expect(dialog.lastDate, DateTime(9999, 12, 31)); + + await robot.cancelPicker(); + }); + }); +} + +(GenUiHost, String) setup(String componentId, Map props) { + final catalog = Catalog([ + CoreCatalogItems.dateTimeInput, + ], catalogId: 'test_catalog'); + + final manager = A2uiMessageProcessor(catalogs: [catalog]); + const surfaceId = 'testSurface'; + + final components = [ + Component(id: componentId, componentProperties: {'DateTimeInput': props}), + ]; + + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + BeginRendering( + surfaceId: surfaceId, + root: componentId, + catalogId: 'test_catalog', + ), + ); + + return (manager, surfaceId); +} + +class DateTimeInputRobot { + final WidgetTester tester; + + DateTimeInputRobot(this.tester); + + Future pumpSurface(GenUiHost manager, String surfaceId) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -50,7 +227,44 @@ void main() { ), ), ); + await tester.pumpAndSettle(); + } - expect(find.text('2025-10-15'), findsOneWidget); - }); + Future openPicker(String componentId) async { + await tester.tap(find.byKey(Key(componentId))); + await tester.pumpAndSettle(); + } + + Future selectDate(String day) async { + await tester.tap(find.text(day)); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + } + + Future cancelPicker() async { + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + } + + void expectInputText(String componentId, String text) { + final Finder finder = find.byKey(Key('${componentId}_text')); + expect(finder, findsOneWidget); + final String actualText = tester.widget(finder).data!; + if (actualText != text) { + print('EXPECTATION FAILED: Expected "$text", found "$actualText"'); + } + expect(actualText, text); + } + + void expectPickerText(String text) { + expect(find.text(text), findsOneWidget); + } + + void expectTimePickerVisible() { + expect(find.text('Select time'), findsOneWidget); + } + + void expectTimePickerHidden() { + expect(find.text('Select time'), findsNothing); + } }