diff --git a/lib/helpers/config.dart b/lib/helpers/config.dart index cadd7fa..302e20c 100644 --- a/lib/helpers/config.dart +++ b/lib/helpers/config.dart @@ -44,6 +44,9 @@ class AppConfig extends ChangeNotifier { return _currentAircraft!.backendInfo['flightlog_spreadsheet_id'] != null && _currentAircraft!.backendInfo['flightlog_sheet_name'] != null && _currentAircraft!.noPilotName != null; + case 'activities': + return _currentAircraft!.backendInfo['activities_spreadsheet_id'] != null && + _currentAircraft!.backendInfo['activities_sheet_name'] != null; default: throw Exception('Unknown feature: $feature'); } @@ -72,6 +75,13 @@ class AppConfig extends ChangeNotifier { }; } + Map get activitiesBackendInfo { + return { + 'spreadsheet_id': _currentAircraft!.backendInfo['activities_spreadsheet_id'], + 'sheet_name': _currentAircraft!.backendInfo['activities_sheet_name'], + }; + } + String get locationName { return _currentAircraft!.locationName; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0ca84a4..351038c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,6 +11,7 @@ "mainNav_bookFlight": "Bookings", "mainNav_logBook": "Log book", + "mainNav_activities": "Activities", "mainNav_about": "Info", "button_goToday": "Today", @@ -108,6 +109,12 @@ "flightLogModal_dialog_delete_message": "You are deleting a registered flight. You won't be able to undo this!", "flightLogModal_dialog_working": "Please wait...", + "activities_title": "Activities", + "activities_button_error_retry": "Retry", + "activities_error_noItemsFound": "Nothing to do!", + "activities_error_firstPageIndicator": "Something went wrong.", + "activities_error_newPageIndicator": "Something went wrong. Tap to retry.", + "addAircraft_title": "Setup aircraft", "addAircraft_text1": "Please type in the address to the aircraft data and its password.", "addAircraft_label_address": "Address", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9d82e30..bee3251 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -8,6 +8,7 @@ "mainNav_bookFlight": "Prenota", "mainNav_logBook": "Log book", + "mainNav_activities": "Attività", "mainNav_about": "Info", "button_goToday": "Oggi", @@ -91,6 +92,12 @@ "flightLogModal_dialog_delete_message": "Stai cancellando un volo registrato. Non potrai recuperarlo!", "flightLogModal_dialog_working": "Un attimo...", + "activities_title": "Attività", + "activities_button_error_retry": "Riprova", + "activities_error_noItemsFound": "Nulla da segnalare!", + "activities_error_firstPageIndicator": "Qualcosa è andato storto.", + "activities_error_newPageIndicator": "Qualcosa è andato storto. Tocca per riprovare.", + "addAircraft_title": "Configura aereo", "addAircraft_text1": "Inserisci l'indirizzo della configurazione dell'aereo e la sua password.", "addAircraft_label_address": "Indirizzo", diff --git a/lib/main.dart b/lib/main.dart index 52ec4f9..7eed505 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,10 +18,12 @@ import 'helpers/config.dart'; import 'helpers/googleapis.dart'; import 'helpers/utils.dart'; import 'screens/about/about_screen.dart'; +import 'screens/activities/activities_screen.dart'; import 'screens/aircraft_select/aircraft_data_screen.dart'; import 'screens/book_flight/book_flight_screen.dart'; import 'screens/flight_log/flight_log_screen.dart'; import 'screens/pilot_select/pilot_select_screen.dart'; +import 'services/activities_services.dart'; import 'services/book_flight_services.dart'; import 'services/flight_log_services.dart'; @@ -129,6 +131,8 @@ class _MyAppState extends State { MainNavigation(appConfig) : const SizedBox.shrink(), 'pilot-select': (context) => appConfig.currentAircraft != null ? const PilotSelectScreen() : const SizedBox.shrink(), + 'activities': (context) => appConfig.currentAircraft != null ? + const ActivitiesScreen() : const SizedBox.shrink(), 'aircraft-data': (context) => const SetAircraftDataScreen(), }, debugShowCheckedModeBanner: false, @@ -158,11 +162,13 @@ class MainNavigation extends StatefulWidget { final BookFlightCalendarService? bookFlightCalendarService; final FlightLogBookService? flightLogBookService; + final ActivitiesService? activitiesService; // TODO other services one day... const MainNavigation(this.appConfig, {Key? key}) : bookFlightCalendarService = null, flightLogBookService = null, + activitiesService = null, super(key: key); /// Mainly for integration testing. @@ -171,6 +177,7 @@ class MainNavigation extends StatefulWidget { Key? key, this.bookFlightCalendarService, this.flightLogBookService, + this.activitiesService, }) : super(key: key); @override @@ -181,6 +188,7 @@ class _MainNavigationState extends State { late PlatformTabController _tabController; late BookFlightCalendarService? _bookFlightCalendarService; late FlightLogBookService? _flightLogBookService; + late ActivitiesService? _activitiesService; @override void initState() { @@ -205,6 +213,8 @@ class _MainNavigationState extends State { (widget.appConfig.hasFeature('book_flight') ? BookFlightCalendarService(account!, widget.appConfig.googleCalendarId) : null); _flightLogBookService = widget.flightLogBookService ?? (widget.appConfig.hasFeature('flight_log') ? FlightLogBookService(account!, widget.appConfig.flightlogBackendInfo) : null); + _activitiesService = widget.activitiesService ?? + (widget.appConfig.hasFeature('activities') ? ActivitiesService(account!, widget.appConfig.activitiesBackendInfo) : null); } @override @@ -235,6 +245,10 @@ class _MainNavigationState extends State { value: _flightLogBookService, child: const FlightLogScreen(), ), + if (widget.appConfig.hasFeature('activities')) () => Provider.value( + value: _activitiesService, + child: const ActivitiesScreen(), + ), () => const AboutScreen(), ][index](); } @@ -256,6 +270,13 @@ class _MainNavigationState extends State { label: AppLocalizations.of(context)!.mainNav_logBook, tooltip: '', ), + if (widget.appConfig.hasFeature('activities')) BottomNavigationBarItem( + icon: Icon(PlatformIcons(context).flag, + key: const Key('nav_activities'), + ), + label: AppLocalizations.of(context)!.mainNav_activities, + tooltip: '', + ), BottomNavigationBarItem( icon: Icon(PlatformIcons(context).info, key: const Key('nav_info'), diff --git a/lib/models/activities_models.dart b/lib/models/activities_models.dart new file mode 100644 index 0000000..d96f1db --- /dev/null +++ b/lib/models/activities_models.dart @@ -0,0 +1,80 @@ + +/// Activity types. The code is used by the backend. +/// A type can be a task (i.e. can be marked as done) or not. +/// A type can also trigger an alert: it will be notified to all pilots (may be overridden in entry) +enum ActivityType { + /// A simple note. Not a task. + note(10, false, false), + /// A minor task to be done (e.g. washing) + minor(30, true, false), + /// A non-critical issue requiring a notice to all pilots. + notice(70, false, true), + /// A relevant, important issue that must be addressed (although aircraft is able to fly). + important(90, true, true), + /// A critical issue, possibly related to flight security. Must be addressed before flight. + critical(100, true, true); + + const ActivityType(this.code, this.task, this.alert); + + final int code; + final bool task; + final bool alert; + + static ActivityType fromCode(int code) => + ActivityType.values.firstWhere((element) => element.code == code); +} + +enum ActivityStatus { + todo('TODO'), + inProgress('IN PROGRESS'), + done('DONE'); + + const ActivityStatus(this.label); + + final String label; + + static ActivityStatus fromLabel(String label) => + ActivityStatus.values.firstWhere((element) => element.label == label); +} + +class ActivityEntry { + ActivityEntry({ + this.id, + required this.type, + required this.creationDate, + this.status, + this.dueDate, + required this.author, + required this.summary, + this.description, + this.alert, + }); + + /// Entry ID. Used by the backend. + String? id; + + /// Entry type. + ActivityType type; + + /// Creation date. + DateTime creationDate; + + /// Entry status. + ActivityStatus? status; + + /// Due date (if any). + DateTime? dueDate; + + /// Pilot that created this entry. + String author; + + /// Entry summary. + String summary; + + /// Entry description. + String? description; + + /// Trigger an alert to all pilots. If null, the alert flag in [type] will be used. + bool? alert; + +} diff --git a/lib/screens/activities/activities_list.dart b/lib/screens/activities/activities_list.dart new file mode 100644 index 0000000..50e4642 --- /dev/null +++ b/lib/screens/activities/activities_list.dart @@ -0,0 +1,371 @@ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import 'package:timeline_tile/timeline_tile.dart'; + +import '../../helpers/utils.dart'; +import '../../models/activities_models.dart'; +import '../../services/activities_services.dart'; + +final Logger _log = Logger((ActivityEntry).toString()); + +class ActivitiesList extends StatefulWidget { + + final ActivitiesListController controller; + final ActivitiesService activitiesService; + + const ActivitiesList({ + Key? key, + required this.controller, + required this.activitiesService, + }) : super(key: key); + + @override + State createState() => _ActivitiesListState(); +} + +class _ActivitiesListState extends State { + + final _pagingController = PagingController( + firstPageKey: 1, + ); + var _firstTime = true; + + @override + void initState() { + _pagingController.addPageRequestListener((pageKey) => _fetchPage(pageKey)); + widget.controller.addListener(_refresh); + super.initState(); + } + + @override + void didUpdateWidget(ActivitiesList oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_refresh); + widget.controller.addListener(_refresh); + } + } + + Future _fetchPage(int pageKey) async { + try { + if (_firstTime) { + await widget.activitiesService.reset(); + } + + final items = widget.activitiesService.hasMoreData() ? + await widget.activitiesService.fetchItems() : []; + final page = items.toList(growable: false).reversed.toList(growable: false); + + if (_firstTime) { + if (page.isNotEmpty) { + widget.controller.empty = false; + } + else { + widget.controller.empty = true; + } + _firstTime = false; + } + + if (widget.activitiesService.hasMoreData()) { + _pagingController.appendPage(page, pageKey + 1); + } + else { + _pagingController.appendLastPage(page); + } + } + catch (error, stacktrace) { + _log.warning('error loading activities data', error, stacktrace); + _pagingController.error = error; + } + } + + Future _refresh() async { + _firstTime = true; + return Future.sync(() => _pagingController.refresh()); + } + + Widget noItemsFoundIndicator(BuildContext context) => + FirstPageExceptionIndicator( + title: AppLocalizations.of(context)!.activities_error_noItemsFound, + onTryAgain: _refresh, + ); + + Widget firstPageErrorIndicator(BuildContext context) => + FirstPageExceptionIndicator( + title: AppLocalizations.of(context)!.activities_error_firstPageIndicator, + message: getExceptionMessage(_pagingController.error), + onTryAgain: _refresh, + ); + + Widget newPageErrorIndicator(BuildContext context) => + NewPageErrorIndicator( + message: AppLocalizations.of(context)!.activities_error_newPageIndicator, + onTap: _pagingController.retryLastFailedRequest, + ); + + Widget _buildListItem(BuildContext context, ActivityEntry item, int index) { + return _EntryListItem(entry: item); + } + + /// FIXME using PagedSliverList within a CustomScrollView for Material leads to errors + @override + Widget build(BuildContext context) { + // TODO test scrolling physics with no content + if (isCupertino(context)) { + return CustomScrollView( + slivers: [ + CupertinoSliverRefreshControl( + onRefresh: () => _refresh(), + ), + PagedSliverList.separated( + pagingController: _pagingController, + separatorBuilder: (context, index) => const SizedBox.shrink(), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: _buildListItem, + firstPageErrorIndicatorBuilder: (context) => firstPageErrorIndicator(context), + newPageErrorIndicatorBuilder: (context) => newPageErrorIndicator(context), + noItemsFoundIndicatorBuilder: (context) => noItemsFoundIndicator(context), + firstPageProgressIndicatorBuilder: (context) => const CupertinoActivityIndicator(radius: 20), + newPageProgressIndicatorBuilder: (context) => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: CupertinoActivityIndicator(radius: 16), + ), + ), + ), + ], + ); + } + else { + return RefreshIndicator( + onRefresh: () => _refresh(), + child: PagedListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + pagingController: _pagingController, + separatorBuilder: (context, index) => const SizedBox.shrink(), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: _buildListItem, + firstPageErrorIndicatorBuilder: (context) => firstPageErrorIndicator(context), + newPageErrorIndicatorBuilder: (context) => newPageErrorIndicator(context), + noItemsFoundIndicatorBuilder: (context) => noItemsFoundIndicator(context), + ), + ), + ); + } + } + + @override + void dispose() { + _pagingController.dispose(); + widget.controller.removeListener(_refresh); + super.dispose(); + } + +} + +class ActivitiesListController extends ValueNotifier { + ActivitiesListController() : super(const ActivitiesListState()); + + set empty(bool? empty) { + value = ActivitiesListState( + empty: empty, + ); + } + + bool? get empty { + return value.empty; + } + + void reset() { + value = const ActivitiesListState(); + } + +} + +@immutable +class ActivitiesListState { + const ActivitiesListState({ + this.empty + }); + + final bool? empty; +} + +class _EntryListItem extends StatelessWidget { + + const _EntryListItem({ + Key? key, + required this.entry, + }) : super(key: key); + + final ActivityEntry entry; + + static final _dateFormatter = DateFormat.yMEd(); + + Color? _backgroundColor(BuildContext context, ActivityEntry entry) { + if (entry.type == ActivityType.critical && entry.status != ActivityStatus.done) { + return Colors.red.shade800; + } + return isCupertino(context) ? CupertinoColors.systemFill.resolveFrom(context) : null; + } + + Color? _textColor(ActivityEntry entry) { + if (entry.type == ActivityType.critical && entry.status != ActivityStatus.done) { + return Colors.white; + } + return null; + } + + Widget _entryIndicator(ActivityEntry entry) { + const kIconSize = 20.0; + final IconData icon; + final Color bgColor; + final Color iconColor; + if (entry.status == ActivityStatus.done) { + bgColor = const Color(0xff6ad192); + iconColor = Colors.white; + icon = Icons.check; + } + else { + switch (entry.type) { + case ActivityType.note: + bgColor = Colors.blue; + iconColor = Colors.white; + icon = Icons.note_alt_outlined; + break; + case ActivityType.minor: + bgColor = Colors.teal; + iconColor = Colors.white; + icon = Icons.task_outlined; + break; + case ActivityType.notice: + bgColor = Colors.deepPurpleAccent; + iconColor = Colors.white; + icon = Icons.notifications_active_outlined; + break; + case ActivityType.important: + bgColor = Colors.amber; + iconColor = Colors.white; + icon = Icons.warning_amber_outlined; + break; + case ActivityType.critical: + bgColor = Colors.red; + iconColor = Colors.white; + icon = Icons.block_outlined; + break; + default: + throw UnsupportedError("Unknown type: ${entry.type.name}"); + } + } + + return Stack( + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: bgColor, + ), + ), + Positioned.fill( + child: Align( + alignment: Alignment.center, + child: SizedBox( + height: 30, + width: 30, + child: Icon( + icon, + size: kIconSize, + color: iconColor, + ), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final summaryTextStyle = isCupertino(context) ? + CupertinoTheme.of(context).textTheme.textStyle.copyWith(color: _textColor(entry)) : + Theme.of(context).textTheme.headline5!.copyWith(color: _textColor(entry)); + + final dateBackgroundColor = isCupertino(context) ? + CupertinoColors.link.resolveFrom(context) : + Theme.of(context).primaryColorLight; + final dateTextColor = ThemeData.estimateBrightnessForColor(dateBackgroundColor) == Brightness.light ? + Colors.black : Colors.white; + final dateTextStyle = isCupertino(context) ? + CupertinoTheme.of(context).textTheme.textStyle.copyWith( + fontSize: 14, + color: dateTextColor, + ) : + Theme.of(context).textTheme.labelMedium!.copyWith( + color: dateTextColor, + ); + final contentTextStyle = isCupertino(context) ? + CupertinoTheme.of(context).textTheme.textStyle.copyWith(fontSize: 14, color: _textColor(entry)) : + Theme.of(context).textTheme.bodyMedium!.copyWith(color: _textColor(entry)); + + final content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.summary, style: summaryTextStyle), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + color: dateBackgroundColor, + ), + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0), + child: Text(_dateFormatter.format(entry.creationDate), style: dateTextStyle) + ), + if (entry.description != null) const SizedBox(height: 4), + if (entry.description != null) Text(entry.description!, style: contentTextStyle), + //const SizedBox(height: 8), + ], + ), + ); + + return TimelineTile( + alignment: TimelineAlign.manual, + lineXY: 0.05, + indicatorStyle: IndicatorStyle( + padding: const EdgeInsets.all(8), + indicatorXY: 0.5, + drawGap: true, + width: 30, + height: 30, + indicator: _entryIndicator(entry), + ), + endChild: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: isCupertino(context) ? + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15.0), + color: _backgroundColor(context, entry), + ), + // no shadow, so we manually create a margin + margin: const EdgeInsets.symmetric(vertical: 4), + child: content, + ) : + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + color: _backgroundColor(context, entry), + elevation: 5, + child: content, + ), + ), + ); + } + +} diff --git a/lib/screens/activities/activities_screen.dart b/lib/screens/activities/activities_screen.dart new file mode 100644 index 0000000..77d5e6c --- /dev/null +++ b/lib/screens/activities/activities_screen.dart @@ -0,0 +1,66 @@ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:provider/provider.dart'; + +// TODO import '../../helpers/config.dart'; +import '../../helpers/cupertinoplus.dart'; +import '../../helpers/utils.dart'; +import '../../services/activities_services.dart'; +import 'activities_list.dart'; + +class ActivitiesScreen extends StatefulWidget { + const ActivitiesScreen({Key? key}) : super(key: key); + + @override + State createState() => _ActivitiesScreenState(); +} + +class _ActivitiesScreenState extends State { + + late ActivitiesListController _activitiesController; + // TODO late AppConfig _appConfig; + late ActivitiesService _activitiesService; + + @override + void initState() { + super.initState(); + _activitiesController = ActivitiesListController(); + } + + @override + void didChangeDependencies() { + // TODO _appConfig = Provider.of(context, listen: false); + _activitiesService = Provider.of(context, listen: false); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + iosContentPadding: true, + // TODO add button on iOS, FAB on Android + appBar: PlatformAppBar( + title: Text(AppLocalizations.of(context)!.activities_title), + automaticallyImplyLeading: false, + material: (context, platform) => MaterialAppBarData( + toolbarHeight: MediaQuery.of(context).orientation == Orientation.portrait ? + kPortraitToolbarHeight : kLandscapeToolbarHeight, + ), + ), + cupertino: (context, platform) => CupertinoPageScaffoldData( + backgroundColor: kCupertinoDialogScaffoldBackgroundColor(context), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: ActivitiesList( + controller: _activitiesController, + activitiesService: _activitiesService, + ), + ), + ); + } + +} diff --git a/lib/services/activities_services.dart b/lib/services/activities_services.dart new file mode 100644 index 0000000..352e921 --- /dev/null +++ b/lib/services/activities_services.dart @@ -0,0 +1,100 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import '../helpers/googleapis.dart'; +import '../models/activities_models.dart'; + +/// Items per page to fetch. +const _kItemsPerPage = 20; +/// Cell containing the row count. +const _kSheetCountRange = 'A1'; + +final Logger _log = Logger((ActivityEntry).toString()); + +/// A primitive way to abstract the real activities service. +class ActivitiesService { + late final GoogleServiceAccountService _accountService; + late final String _spreadsheetId; + late final String _sheetName; + GoogleSheetsService? _client; + int _lastId = 0; + + ActivitiesService(GoogleServiceAccountService accountService, Map properties) { + _accountService = accountService; + _spreadsheetId = properties['spreadsheet_id']!; + _sheetName = properties['sheet_name']!; + } + + @visibleForTesting + set client(GoogleSheetsService client) { + _client = client; + } + + @visibleForTesting + set lastId(int lastId) { + _lastId = lastId; + } + + @visibleForTesting + int get lastId =>_lastId; + + Future _ensureService() { + if (_client != null) { + return Future.value(_client); + } + else { + return _accountService.getAuthenticatedClient().then((client) { + _client = GoogleSheetsService(client); + return _client!; + }); + } + } + + /// Data range generator. +2 because the index is 0-based and to skip the header row. + _sheetDataRange(first, last) => 'A${first + 2}:J${last + 2}'; + + Future reset() { + return _ensureService().then((client) => + client.getRows(_spreadsheetId, _sheetName, _kSheetCountRange).then((value) { + if (value.values == null) { + throw const FormatException('No data found on sheet.'); + } + _lastId = int.parse(value.values![0][0].toString()); + _log.finest('lastId is $_lastId'); + }) + ); + } + + Future> fetchItems() => + _ensureService().then((client) { + final lastId = _lastId - 1; + _lastId = max(_lastId - _kItemsPerPage, 0); + final firstId = _lastId; + _log.fine('getting rows from $firstId to $lastId (range: ${_sheetDataRange(firstId, lastId)})'); + return client.getRows(_spreadsheetId, _sheetName, _sheetDataRange(firstId, lastId)).then((value) { + if (value.values == null) { + throw const FormatException('No data found on sheet.'); + } + _log.finest(value.values); + return value.values!.mapIndexed((index, rowData) => ActivityEntry( + id: (firstId + index + 1).toString(), + creationDate: dateFromGsheets((rowData[1] as int).toDouble()), + type: ActivityType.fromCode(rowData[2] as int), + status: rowData[3] is String && (rowData[3] as String).isNotEmpty ? ActivityStatus.fromLabel(rowData[3] as String) : null, + dueDate: rowData[4] is int ? dateFromGsheets((rowData[4] as int).toDouble()) : null, + author: rowData[5] as String, + summary: rowData[6] as String, + description: rowData.length > 7 && rowData[7] is String && (rowData[7] as String).isNotEmpty ? rowData[7] as String : null, + // TODO alert: + )); + }); + }); + + bool hasMoreData() { + return _lastId > 0; + } + +} diff --git a/pubspec.lock b/pubspec.lock index b86dbba..d007558 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -918,6 +918,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.13" + timeline_tile: + dependency: "direct main" + description: + name: timeline_tile + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a2158b6..7f0d861 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.2.0+23 environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" dependencies: adaptive_dialog: 1.6.4+2 @@ -54,6 +54,7 @@ dependencies: syncfusion_flutter_calendar: 20.2.38 syncfusion_localizations: 20.2.38 syncfusion_flutter_core: 20.2.38 + timeline_tile: 2.0.0 timezone: 0.8.0 url_launcher: 6.1.5 validators: 3.0.0