From 0b606ef3cb3ac5d207d44be92b83743afa532998 Mon Sep 17 00:00:00 2001 From: mjarkk Date: Sun, 13 Mar 2022 19:28:45 +0100 Subject: [PATCH] WorldClockTab: Add time in other cities This adds a button that pops up a window where you can select a city. After chosing the city the city is added to the WorldClockTab and the time in that spesific city is displayed. the selected cities are currently saved in memory thus reloading the app will cause the clock to forget what you've selected. --- lib/main.dart | 35 +---- lib/worldClock/addTimezoneScreen.dart | 131 ++++++++++++++++ lib/worldClock/tab.dart | 210 ++++++++++++++++++++++++++ lib/worldClock/timezones.dart | 71 +++++++++ lib/worldClock/utils.dart | 41 +++++ pubspec.lock | 9 +- pubspec.yaml | 1 + 7 files changed, 465 insertions(+), 33 deletions(-) create mode 100644 lib/worldClock/addTimezoneScreen.dart create mode 100644 lib/worldClock/tab.dart create mode 100644 lib/worldClock/timezones.dart create mode 100644 lib/worldClock/utils.dart diff --git a/lib/main.dart b/lib/main.dart index e07051f..e5134bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,8 +21,11 @@ import 'package:flutter/material.dart'; import './timer/tab.dart'; import './keyboard.dart'; +import './worldClock/tab.dart'; +import './worldClock/timezones.dart'; void main() { + setupTimezoneInfo(); runApp(new Clock()); } @@ -141,38 +144,6 @@ class _ClockApp extends State with TickerProviderStateMixin { } } -class WorldClockTab extends StatefulWidget { - @override - _WorldClockTabState createState() => _WorldClockTabState(); -} - -class _WorldClockTabState extends State { - DateTime _datetime = DateTime.now(); - Timer? _ctimer; - - @override - void deactivate() { - _ctimer?.cancel(); - super.deactivate(); - } - - @override - Widget build(BuildContext context) { - if (_ctimer == null) - _ctimer = Timer.periodic(Duration(seconds: 1), (me) { - _datetime = DateTime.now(); - setState(() {}); - }); - return Material( - child: Center( - child: Text( - "${_datetime.hour}:${_datetime.minute < 10 ? "0" + _datetime.minute.toString() : _datetime.minute}:${_datetime.second < 10 ? "0" + _datetime.second.toString() : _datetime.second}", - style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold), - )), - ); - } -} - class AlarmsTab extends StatelessWidget { @override Widget build(BuildContext context) { diff --git a/lib/worldClock/addTimezoneScreen.dart b/lib/worldClock/addTimezoneScreen.dart new file mode 100644 index 0000000..e6d9256 --- /dev/null +++ b/lib/worldClock/addTimezoneScreen.dart @@ -0,0 +1,131 @@ +/* +Copyright 2022 The dahliaOS Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'package:flutter/material.dart'; + +import './timezones.dart'; + +class AddTimezoneScreen extends StatefulWidget { + const AddTimezoneScreen({required this.onSelect}); + + final void Function(CityTimeZone cityTZ) onSelect; + + @override + State createState() => _AddTimezoneScreenState(); +} + +class _AddTimezoneScreenState extends State { + List options = timezonesOfCities; + + onInput(String query) { + String normalizedQuery = query.toLowerCase(); + options = timezonesOfCities + .where((e) => e.city.toLowerCase().contains(normalizedQuery)) + .toList(); + setState(() {}); + } + + onSearchSubmit() { + if (options.isNotEmpty) onSelect(options.first); + } + + onSelect(CityTimeZone option) { + widget.onSelect(option); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + elevation: 0, + toolbarHeight: 75, + title: Text('Choose a city'), + ), + body: Column( + children: [ + _SearchField( + onInput: onInput, + onSubmit: onSearchSubmit, + ), + _Options(options: options, onSelect: onSelect), + ], + ), + ); + } +} + +class _Options extends StatelessWidget { + const _Options({required this.options, this.onSelect}); + + final List options; + final void Function(CityTimeZone)? onSelect; + + @override + Widget build(BuildContext context) { + return Expanded( + child: ListView.builder( + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final CityTimeZone option = options[index]; + + return TextButton( + key: Key(index.toString()), + style: TextButton.styleFrom( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.all(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option.city, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + Text( + option.prettyUtcOffset, + style: TextStyle(fontSize: 12), + ), + ], + ), + onPressed: onSelect != null ? () => onSelect!(option) : null, + ); + }, + ), + ); + } +} + +class _SearchField extends StatelessWidget { + const _SearchField({required this.onInput, this.onSubmit}); + + final void Function(String s) onInput; + final void Function()? onSubmit; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: TextField( + autofocus: true, + decoration: InputDecoration(labelText: "City"), + onChanged: onInput, + onSubmitted: onSubmit != null ? (String s) => onSubmit!() : null, + ), + ); + } +} diff --git a/lib/worldClock/tab.dart b/lib/worldClock/tab.dart new file mode 100644 index 0000000..d9c14af --- /dev/null +++ b/lib/worldClock/tab.dart @@ -0,0 +1,210 @@ +/* +Copyright 2022 The dahliaOS Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import './timezones.dart'; +import './utils.dart'; +import './addTimezoneScreen.dart'; + +class WorldClockTab extends StatefulWidget { + @override + _WorldClockTabState createState() => _WorldClockTabState(); +} + +Map cityTimezones = {}; + +class _WorldClockTabState extends State { + DateTime datetime = DateTime.now(); + Timer? timer; + + addCityTimezone(CityTimeZone cityTimezone) => + setState(() => cityTimezones[cityTimezone.city] = cityTimezone); + + @override + void deactivate() { + timer?.cancel(); + super.deactivate(); + } + + @override + Widget build(BuildContext context) { + if (timer == null) + timer = Timer.periodic(Duration(seconds: 1), (me) { + datetime = DateTime.now(); + setState(() {}); + }); + + final List children = [ + _LocalTime(datetime: datetime, key: Key('local-time')) + ]; + cityTimezones.forEach((key, cityTZ) => children.add(_UTCTime( + key: Key(key), + city: cityTZ, + localCurrentTime: datetime, + onRemove: () => setState(() => cityTimezones.remove(key)), + ))); + children.add(Container(height: 70, key: Key('bottom-spacer'))); + + return Material( + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + ListView( + children: children, + ), + _AddButton(onAdd: addCityTimezone), + ], + ), + ); + } +} + +class _AddButton extends StatelessWidget { + const _AddButton({required this.onAdd}); + + final void Function(CityTimeZone cityTimezone) onAdd; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), padding: const EdgeInsets.all(14)), + child: Icon( + Icons.add, + size: 32, + ), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddTimezoneScreen(onSelect: onAdd)), + ), + ), + ); + } +} + +class _LocalTime extends StatelessWidget { + _LocalTime({required this.datetime, Key? key}) : super(key: key); + + final DateTime datetime; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Text( + _formatTime(datetime, true), + style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold), + ), + Text( + "${weekDayToString[datetime.weekday]} ${datetime.day} ${monthToString[datetime.month]} ${datetime.year}", + style: Theme.of(context).textTheme.caption), + ], + )); + } +} + +class _UTCTime extends StatelessWidget { + const _UTCTime({ + required this.city, + required this.localCurrentTime, + required this.onRemove, + Key? key, + }) : super(key: key); + + final DateTime localCurrentTime; + final CityTimeZone city; + final void Function() onRemove; + + @override + Widget build(BuildContext context) { + final DateTime timeInCity = city.now; + + final String dayDiff = timeInCity.day == localCurrentTime.day + ? 'Today' + : timeInCity.isBefore(localCurrentTime) + ? 'Yesterday' + : 'Tomorrow'; + + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(city.city, style: Theme.of(context).textTheme.headline6), + Text( + "${dayDiff}, ${city.prettyOffset(localCurrentTime.timeZoneOffset)}"), + ], + ), + Spacer(), + Text( + _formatTime(timeInCity, false), + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + _UTCTimeOptions(onRemove: onRemove), + ], + ), + )); + } +} + +enum _UTCTimeOptionsEnum { + Remove, +} + +class _UTCTimeOptions extends StatelessWidget { + const _UTCTimeOptions({required this.onRemove}); + + final void Function() onRemove; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_UTCTimeOptionsEnum>( + icon: Icon(Icons.more_vert), + onSelected: (_UTCTimeOptionsEnum result) => onRemove(), + itemBuilder: (BuildContext context) => [ + const PopupMenuItem<_UTCTimeOptionsEnum>( + value: _UTCTimeOptionsEnum.Remove, + child: Text('Remove'), + ), + ], + ); + } +} + +String _formatTime(DateTime dt, bool showSeconds) { + var resp = + "${dt.hour}:${dt.minute < 10 ? "0" + dt.minute.toString() : dt.minute}"; + + return showSeconds + ? resp + ":${dt.second < 10 ? "0" + dt.second.toString() : dt.second}" + : resp; +} diff --git a/lib/worldClock/timezones.dart b/lib/worldClock/timezones.dart new file mode 100644 index 0000000..1d5e2bd --- /dev/null +++ b/lib/worldClock/timezones.dart @@ -0,0 +1,71 @@ +/* +Copyright 2022 The dahliaOS Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +class CityTimeZone { + CityTimeZone(this.key, this.timezoneLocation, this.city); + + final String key; + final tz.Location timezoneLocation; + final String city; + + DateTime get now => tz.TZDateTime.now(timezoneLocation); + + tz.TimeZone get timezone => timezoneLocation.zones.last; + + // prettyOffset generate a human readable offset + // The result is in the form of "+H:MM" or "-H:MM" + // By the default UTC +0 is taken as the base value used to generate the offset from + // If you want a different base value you can set the base argument to do so + String prettyOffset([Duration? base]) { + final int extraOffset = base?.inMilliseconds ?? 0; + final int totalOffsetMinutes = + (timezone.offset - extraOffset) ~/ 1000 ~/ 60; + final int offsetHours = totalOffsetMinutes ~/ 60; + final String offsetMinutes = + (totalOffsetMinutes % 60).toString().padLeft(2, '0'); + + return "${offsetHours >= 0 ? "+" : ""}$offsetHours:$offsetMinutes H"; + } + + // Yields a human readable Utc offset based on UTC +0 + // If there is a zone abbreviation available it's placed before the value + String get prettyUtcOffset { + final abbreviation = timezone.abbreviation; + final String zoneNamePrefix = abbreviation.isNotEmpty && + abbreviation[0] != '-' && + abbreviation[0] != '+' + ? abbreviation + ' ' + : ''; + + return zoneNamePrefix + prettyOffset(); + } +} + +List timezonesOfCities = []; + +setupTimezoneInfo() { + tz.initializeTimeZones(); + tz.timeZoneDatabase.locations.forEach((key, value) { + timezonesOfCities.add(CityTimeZone( + key, + value, + key.split('/').last.replaceAll('_', ' '), + )); + }); +} diff --git a/lib/worldClock/utils.dart b/lib/worldClock/utils.dart new file mode 100644 index 0000000..e23646a --- /dev/null +++ b/lib/worldClock/utils.dart @@ -0,0 +1,41 @@ +/* +Copyright 2022 The dahliaOS Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +final Map monthToString = { + 1: 'January', + 2: 'February', + 3: 'March', + 4: 'April', + 5: 'May', + 6: 'June', + 7: 'July', + 8: 'August', + 9: 'September', + 10: 'October', + 11: 'November', + 12: 'December', +}; + +final Map weekDayToString = { + 0: 'Monday', + 1: 'Tuesday', + 2: 'March', + 3: 'Wednesday', + 4: 'Thursday', + 5: 'Friday', + 6: 'Saturday', + 7: 'Sunday', +}; diff --git a/pubspec.lock b/pubspec.lock index 4049566..125fff8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,7 +42,7 @@ packages: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0" + version: "1.16.0" fake_async: dependency: transitive description: @@ -135,6 +135,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.9" + timezone: + dependency: "direct main" + description: + name: timezone + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9d74cc4..9498c7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ environment: dependencies: flutter: sdk: flutter + timezone: ^0.8.0 # The following adds the Cupertino Icons font to your application.