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.