From 17e7aa55f3b394cc3ea690a3f8f104f148ecb3c3 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 15 Jul 2024 01:42:39 +0800 Subject: [PATCH] Filter photos by date in map browser --- app/lib/l10n/app_en.arb | 8 + app/lib/l10n/untranslated-messages.txt | 102 ++++++++- app/lib/widget/map_browser.dart | 50 +++- app/lib/widget/map_browser.g.dart | 44 +++- app/lib/widget/map_browser/bloc.dart | 110 ++++++++- app/lib/widget/map_browser/state_event.dart | 61 ++++- app/lib/widget/map_browser/type.dart | 21 ++ app/lib/widget/map_browser/view.dart | 239 ++++++++++++++++++++ np_datetime/lib/src/date_range.dart | 16 ++ np_datetime/lib/src/time_range.dart | 16 ++ 10 files changed, 637 insertions(+), 30 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9ea6a2ae..0675149b 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1487,6 +1487,14 @@ "trustedCertManagerFailedToRemoveCertError": "Failed to remove certificate", "missingVideoThumbnailHelpDialogTitle": "Having trouble with video thumbnails?", "dontShowAgain": "Don't show again", + "mapBrowserDateRangeLabel": "Date range", + "@mapBrowserDateRangeLabel": { + "description": "Filter photos by date range" + }, + "mapBrowserDateRangeThisMonth": "This month", + "mapBrowserDateRangePrevMonth": "Previous month", + "mapBrowserDateRangeThisYear": "This year", + "mapBrowserDateRangeCustom": "Custom", "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 57d1ebce..e5b40963 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -249,6 +249,11 @@ "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -284,7 +289,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "de": [ @@ -318,7 +328,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "el": [ @@ -455,7 +470,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "es": [ @@ -483,7 +503,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "fi": [ @@ -511,7 +536,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "fr": [ @@ -539,7 +569,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "it": [ @@ -572,7 +607,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "nl": [ @@ -942,6 +982,11 @@ "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -981,7 +1026,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "pt": [ @@ -1029,7 +1079,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "ru": [ @@ -1057,7 +1112,20 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" + ], + + "tr": [ + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "zh": [ @@ -1116,7 +1184,12 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ], "zh_Hant": [ @@ -1269,6 +1342,11 @@ "trustedCertManagerNoHttpsServerError", "trustedCertManagerFailedToRemoveCertError", "missingVideoThumbnailHelpDialogTitle", - "dontShowAgain" + "dontShowAgain", + "mapBrowserDateRangeLabel", + "mapBrowserDateRangeThisMonth", + "mapBrowserDateRangePrevMonth", + "mapBrowserDateRangeThisYear", + "mapBrowserDateRangeCustom" ] } diff --git a/app/lib/widget/map_browser.dart b/app/lib/widget/map_browser.dart index 0b035afc..757d643c 100644 --- a/app/lib/widget/map_browser.dart +++ b/app/lib/widget/map_browser.dart @@ -1,16 +1,18 @@ import 'dart:async'; import 'dart:ui'; +import 'package:clock/clock.dart'; import 'package:copy_with/copy_with.dart'; -import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:intl/intl.dart' as intl; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/di_container.dart'; @@ -18,9 +20,14 @@ import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart'; import 'package:nc_photos/entity/image_location/repo.dart'; import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/stream_extension.dart'; +import 'package:nc_photos/theme.dart'; import 'package:nc_photos/widget/collection_browser.dart'; +import 'package:nc_photos/widget/measure.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_datetime/np_datetime.dart'; import 'package:to_string/to_string.dart'; part 'map_browser.g.dart'; @@ -44,7 +51,7 @@ class MapBrowser extends StatelessWidget { create: (_) => _Bloc( KiwiContainer().resolve(), account: context.read().account, - )..add(const _Init()), + )..add(const _LoadData()), child: const _WrappedMapBrowser(), ); } @@ -69,7 +76,42 @@ class _WrappedMapBrowser extends StatelessWidget { }, ), ], - child: const _MapView(), + child: Stack( + children: [ + const _MapView(), + Positioned.directional( + textDirection: Directionality.of(context), + top: MediaQuery.of(context).padding.top + 8, + end: 8, + child: const _DateRangeToggle(), + ), + _BlocSelector( + selector: (state) => state.isShowDataRangeControlPanel, + builder: (context, isShowAnyPanel) => Positioned.fill( + child: isShowAnyPanel + ? GestureDetector( + onTap: () { + context.addEvent(const _CloseControlPanel()); + }, + ) + : const SizedBox.shrink(), + ), + ), + Positioned( + left: 8, + right: 8, + top: MediaQuery.of(context).padding.top + 8, + child: _BlocSelector( + selector: (state) => state.isShowDataRangeControlPanel, + builder: (context, isShowDataRangeControlPanel) => + _PanelContainer( + isShow: isShowDataRangeControlPanel, + child: const _DateRangeControlPanel(), + ), + ), + ), + ], + ), ), ), ); @@ -79,7 +121,7 @@ class _WrappedMapBrowser extends StatelessWidget { typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; // typedef _BlocListener = BlocListener<_Bloc, _State>; typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; -// typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; extension on BuildContext { _Bloc get bloc => read<_Bloc>(); diff --git a/app/lib/widget/map_browser.g.dart b/app/lib/widget/map_browser.g.dart index 262a5949..43c60435 100644 --- a/app/lib/widget/map_browser.g.dart +++ b/app/lib/widget/map_browser.g.dart @@ -17,6 +17,9 @@ abstract class $_StateCopyWithWorker { {List<_DataPoint>? data, LatLng? initialPoint, Set? markers, + bool? isShowDataRangeControlPanel, + _DateRangeType? dateRangeType, + DateRange? localDateRange, ExceptionEvent? error}); } @@ -28,6 +31,9 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { {dynamic data, dynamic initialPoint = copyWithNull, dynamic markers, + dynamic isShowDataRangeControlPanel, + dynamic dateRangeType, + dynamic localDateRange, dynamic error = copyWithNull}) { return _State( data: data as List<_DataPoint>? ?? that.data, @@ -35,6 +41,10 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { ? that.initialPoint : initialPoint as LatLng?, markers: markers as Set? ?? that.markers, + isShowDataRangeControlPanel: isShowDataRangeControlPanel as bool? ?? + that.isShowDataRangeControlPanel, + dateRangeType: dateRangeType as _DateRangeType? ?? that.dateRangeType, + localDateRange: localDateRange as DateRange? ?? that.localDateRange, error: error == copyWithNull ? that.error : error as ExceptionEvent?); } @@ -64,14 +74,14 @@ extension _$_BlocNpLog on _Bloc { extension _$_StateToString on _State { String _$toString() { // ignore: unnecessary_string_interpolations - return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, error: $error}"; + return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, isShowDataRangeControlPanel: $isShowDataRangeControlPanel, dateRangeType: ${dateRangeType.name}, localDateRange: $localDateRange, error: $error}"; } } -extension _$_InitToString on _Init { +extension _$_LoadDataToString on _LoadData { String _$toString() { // ignore: unnecessary_string_interpolations - return "_Init {}"; + return "_LoadData {}"; } } @@ -82,6 +92,34 @@ extension _$_SetMarkersToString on _SetMarkers { } } +extension _$_OpenDataRangeControlPanelToString on _OpenDataRangeControlPanel { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_OpenDataRangeControlPanel {}"; + } +} + +extension _$_CloseControlPanelToString on _CloseControlPanel { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_CloseControlPanel {}"; + } +} + +extension _$_SetDateRangeTypeToString on _SetDateRangeType { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetDateRangeType {value: ${value.name}}"; + } +} + +extension _$_SetLocalDateRangeToString on _SetLocalDateRange { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetLocalDateRange {value: $value}"; + } +} + extension _$_SetErrorToString on _SetError { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/map_browser/bloc.dart b/app/lib/widget/map_browser/bloc.dart index eb42226f..2c3df23b 100644 --- a/app/lib/widget/map_browser/bloc.dart +++ b/app/lib/widget/map_browser/bloc.dart @@ -6,10 +6,31 @@ class _Bloc extends Bloc<_Event, _State> _Bloc( this._c, { required this.account, - }) : super(_State.init()) { - on<_Init>(_onInit); + }) : super(_State.init( + dateRangeType: _DateRangeType.thisMonth, + localDateRange: + _calcDateRange(clock.now().toDate(), _DateRangeType.thisMonth), + )) { + on<_LoadData>(_onLoadData); on<_SetMarkers>(_onSetMarkers); + on<_OpenDataRangeControlPanel>(_onOpenDataRangeControlPanel); + on<_CloseControlPanel>(_onCloseControlPanel); + on<_SetDateRangeType>(_onSetDateRangeType); + on<_SetLocalDateRange>(_onSetDateRange); on<_SetError>(_onSetError); + + _subscriptions + .add(stream.distinctBy((state) => state.localDateRange).listen((state) { + add(const _LoadData()); + })); + } + + @override + Future close() { + for (final s in _subscriptions) { + s.cancel(); + } + return super.close(); } @override @@ -28,10 +49,16 @@ class _Bloc extends Bloc<_Event, _State> super.onError(error, stackTrace); } - Future _onInit(_Init ev, Emitter<_State> emit) async { + Future _onLoadData(_LoadData ev, Emitter<_State> emit) async { _log.info(ev); - final raw = await _c.imageLocationRepo.getLocations(account); - _log.info("[_onInit] Loaded ${raw.length} markers"); + // convert local DateRange to TimeRange in UTC + final localTimeRange = state.localDateRange.toLocalTimeRange(); + final utcTimeRange = localTimeRange.copyWith( + from: localTimeRange.from?.toUtc(), + to: localTimeRange.to?.toUtc(), + ); + final raw = await _c.imageLocationRepo.getLocations(account, utcTimeRange); + _log.info("[_onLoadData] Loaded ${raw.length} markers"); emit(state.copyWith( data: raw.map(_DataPoint.fromImageLatLng).toList(), initialPoint: state.initialPoint ?? @@ -46,13 +73,86 @@ class _Bloc extends Bloc<_Event, _State> emit(state.copyWith(markers: ev.markers)); } + void _onOpenDataRangeControlPanel( + _OpenDataRangeControlPanel ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + isShowDataRangeControlPanel: true, + )); + } + + void _onCloseControlPanel(_CloseControlPanel ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + isShowDataRangeControlPanel: false, + )); + } + + void _onSetDateRangeType(_SetDateRangeType ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + dateRangeType: ev.value, + localDateRange: ev.value == _DateRangeType.custom + ? null + : _calcDateRange(clock.now().toDate(), ev.value), + )); + } + + void _onSetDateRange(_SetLocalDateRange ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith( + dateRangeType: _DateRangeType.custom, + localDateRange: ev.value, + )); + } + void _onSetError(_SetError ev, Emitter<_State> emit) { _log.info(ev); emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); } + static DateRange _calcDateRange(Date today, _DateRangeType type) { + assert(type != _DateRangeType.custom); + switch (type) { + case _DateRangeType.thisMonth: + return DateRange( + from: today.copyWith(day: 1), + to: today, + toBound: TimeRangeBound.inclusive, + ); + case _DateRangeType.prevMonth: + if (today.month == 1) { + return DateRange( + from: Date(today.year - 1, 12, 1), + to: Date(today.year - 1, 12, 31), + toBound: TimeRangeBound.inclusive, + ); + } else { + return DateRange( + from: Date(today.year, today.month - 1, 1), + to: Date(today.year, today.month, 1).add(day: -1), + toBound: TimeRangeBound.inclusive, + ); + } + case _DateRangeType.thisYear: + return DateRange( + from: today.copyWith(month: 1, day: 1), + to: today, + toBound: TimeRangeBound.inclusive, + ); + case _DateRangeType.custom: + return DateRange( + from: today, + to: today, + toBound: TimeRangeBound.inclusive, + ); + } + } + final DiContainer _c; final Account account; + final _subscriptions = []; + var _isHandlingError = false; } diff --git a/app/lib/widget/map_browser/state_event.dart b/app/lib/widget/map_browser/state_event.dart index da6a4441..145948f2 100644 --- a/app/lib/widget/map_browser/state_event.dart +++ b/app/lib/widget/map_browser/state_event.dart @@ -7,13 +7,22 @@ class _State { required this.data, this.initialPoint, required this.markers, + required this.isShowDataRangeControlPanel, + required this.dateRangeType, + required this.localDateRange, this.error, }); - factory _State.init() { - return const _State( - data: [], - markers: {}, + factory _State.init({ + required _DateRangeType dateRangeType, + required DateRange localDateRange, + }) { + return _State( + data: const [], + markers: const {}, + isShowDataRangeControlPanel: false, + dateRangeType: dateRangeType, + localDateRange: localDateRange, ); } @@ -24,6 +33,10 @@ class _State { final LatLng? initialPoint; final Set markers; + final bool isShowDataRangeControlPanel; + final _DateRangeType dateRangeType; + final DateRange localDateRange; + final ExceptionEvent? error; } @@ -32,8 +45,8 @@ abstract class _Event { } @toString -class _Init implements _Event { - const _Init(); +class _LoadData implements _Event { + const _LoadData(); @override String toString() => _$toString(); @@ -49,6 +62,42 @@ class _SetMarkers implements _Event { final Set markers; } +@toString +class _OpenDataRangeControlPanel implements _Event { + const _OpenDataRangeControlPanel(); + + @override + String toString() => _$toString(); +} + +@toString +class _CloseControlPanel implements _Event { + const _CloseControlPanel(); + + @override + String toString() => _$toString(); +} + +@toString +class _SetDateRangeType implements _Event { + const _SetDateRangeType(this.value); + + @override + String toString() => _$toString(); + + final _DateRangeType value; +} + +@toString +class _SetLocalDateRange implements _Event { + const _SetLocalDateRange(this.value); + + @override + String toString() => _$toString(); + + final DateRange value; +} + @toString class _SetError implements _Event { const _SetError(this.error, [this.stackTrace]); diff --git a/app/lib/widget/map_browser/type.dart b/app/lib/widget/map_browser/type.dart index 4ca232f0..a7b7affc 100644 --- a/app/lib/widget/map_browser/type.dart +++ b/app/lib/widget/map_browser/type.dart @@ -19,3 +19,24 @@ class _DataPoint implements ClusterItem { final LatLng location; final int fileId; } + +enum _DateRangeType { + thisMonth, + prevMonth, + thisYear, + custom, + ; + + String toDisplayString() { + switch (this) { + case thisMonth: + return L10n.global().mapBrowserDateRangeThisMonth; + case prevMonth: + return L10n.global().mapBrowserDateRangePrevMonth; + case thisYear: + return L10n.global().mapBrowserDateRangeThisYear; + case custom: + return L10n.global().mapBrowserDateRangeCustom; + } + } +} diff --git a/app/lib/widget/map_browser/view.dart b/app/lib/widget/map_browser/view.dart index 1acd911d..52580007 100644 --- a/app/lib/widget/map_browser/view.dart +++ b/app/lib/widget/map_browser/view.dart @@ -200,3 +200,242 @@ class _MapViewState extends State<_MapView> { late final _colorHsl = HSLColor.fromColor(Theme.of(context).colorScheme.primaryContainer); } + +class _PanelContainer extends StatefulWidget { + const _PanelContainer({ + required this.isShow, + required this.child, + }); + + @override + State createState() => _PanelContainerState(); + + final bool isShow; + final Widget child; +} + +class _PanelContainerState extends State<_PanelContainer> + with TickerProviderStateMixin { + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: k.animationDurationNormal, + vsync: this, + value: 0, + ); + _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _PanelContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isShow != widget.isShow) { + if (widget.isShow) { + _animationController.animateTo(1); + } else { + _animationController.animateBack(0); + } + } + } + + @override + Widget build(BuildContext context) { + return MatrixTransition( + animation: _animation, + onTransform: (animationValue) => Matrix4.identity() + ..translate(0.0, -(_size.height / 2) * (1 - animationValue), 0.0) + ..scale(1.0, animationValue, 1.0), + child: MeasureSize( + onChange: (size) => setState(() { + _size = size; + }), + child: widget.child, + ), + ); + } + + late AnimationController _animationController; + late Animation _animation; + var _size = Size.zero; +} + +class _DateRangeToggle extends StatelessWidget { + const _DateRangeToggle(); + + @override + Widget build(BuildContext context) { + return FloatingActionButton.small( + onPressed: () { + context.addEvent(const _OpenDataRangeControlPanel()); + }, + child: const Icon(Icons.date_range_outlined), + ); + } +} + +class _DateRangeControlPanel extends StatelessWidget { + const _DateRangeControlPanel(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.elevate(theme.colorScheme.surface, 2), + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + blurRadius: 4, + offset: Offset(0, 2), + color: Colors.black26, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Text( + L10n.global().mapBrowserDateRangeLabel, + style: Theme.of(context).listTileTheme.titleTextStyle, + ), + ), + Expanded( + child: _BlocSelector<_DateRangeType>( + selector: (state) => state.dateRangeType, + builder: (context, dateRangeType) => + DropdownButtonFormField<_DateRangeType>( + items: _DateRangeType.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.toDisplayString()), + )) + .toList(), + value: dateRangeType, + onChanged: (value) { + if (value != null) { + context.addEvent(_SetDateRangeType(value)); + } + }, + ), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: _BlocSelector( + selector: (state) => state.localDateRange, + builder: (context, localDateRange) => _DateField( + localDateRange.from!, + onChanged: (value) { + context.addEvent(_SetLocalDateRange( + localDateRange.copyWith(from: value.toDate()))); + }, + ), + ), + ), + const Text(" - "), + Expanded( + child: _BlocSelector( + selector: (state) => state.localDateRange, + builder: (context, localDateRange) => _DateField( + localDateRange.to!, + onChanged: (value) { + context.addEvent(_SetLocalDateRange( + localDateRange.copyWith(to: value.toDate()))); + }, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} + +class _DateField extends StatefulWidget { + const _DateField( + this.date, { + this.onChanged, + }); + + @override + State createState() => _DateFieldState(); + + final Date date; + final ValueChanged? onChanged; +} + +class _DateFieldState extends State<_DateField> { + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + void didUpdateWidget(covariant _DateField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.date != oldWidget.date) { + _controller.text = _stringify(widget.date); + } + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () async { + final result = await showDatePicker( + context: context, + firstDate: DateTime(1970), + lastDate: clock.now(), + currentDate: widget.date.toLocalDateTime(), + ); + if (result == null) { + return; + } + widget.onChanged?.call(result); + }, + child: IgnorePointer( + child: ExcludeFocus( + child: TextFormField( + controller: _controller, + ), + ), + ), + ), + ); + } + + String _stringify(Date date) { + return intl.DateFormat(intl.DateFormat.YEAR_ABBR_MONTH_DAY, + Localizations.localeOf(context).languageCode) + .format(date.toLocalDateTime()); + } + + late final _controller = TextEditingController(text: _stringify(widget.date)); +} diff --git a/np_datetime/lib/src/date_range.dart b/np_datetime/lib/src/date_range.dart index 9498e644..0d69500f 100644 --- a/np_datetime/lib/src/date_range.dart +++ b/np_datetime/lib/src/date_range.dart @@ -9,6 +9,22 @@ class DateRange { this.toBound = TimeRangeBound.exclusive, }); + /// Return a copy of the current instance with some changed fields. Setting + /// null is not supported + DateRange copyWith({ + Date? from, + TimeRangeBound? fromBound, + Date? to, + TimeRangeBound? toBound, + }) { + return DateRange( + from: from ?? this.from, + fromBound: fromBound ?? this.fromBound, + to: to ?? this.to, + toBound: toBound ?? this.toBound, + ); + } + @override String toString() { return "${fromBound == TimeRangeBound.inclusive ? "[" : "("}" diff --git a/np_datetime/lib/src/time_range.dart b/np_datetime/lib/src/time_range.dart index 90c580b9..8523c892 100644 --- a/np_datetime/lib/src/time_range.dart +++ b/np_datetime/lib/src/time_range.dart @@ -11,6 +11,22 @@ class TimeRange { this.toBound = TimeRangeBound.exclusive, }); + /// Return a copy of the current instance with some changed fields. Setting + /// null is not supported + TimeRange copyWith({ + DateTime? from, + TimeRangeBound? fromBound, + DateTime? to, + TimeRangeBound? toBound, + }) { + return TimeRange( + from: from ?? this.from, + fromBound: fromBound ?? this.fromBound, + to: to ?? this.to, + toBound: toBound ?? this.toBound, + ); + } + @override String toString() { return "${fromBound == TimeRangeBound.inclusive ? "[" : "("}"