diff --git a/lib/api/events.dart b/lib/api/events.dart index 42cba4a4..d1268a40 100644 --- a/lib/api/events.dart +++ b/lib/api/events.dart @@ -93,7 +93,7 @@ extension EventsExtension on API { events = ((jsonDecode(response.body) as Map)['entry'] as Iterable) .cast() .map((eventObject) { - final published = DateTime.parse(eventObject['published']).toLocal(); + final published = DateTime.parse(eventObject['published']); final event = Event( server: server, id: () { @@ -115,10 +115,12 @@ extension EventsExtension on API { '-1', ), title: eventObject['title'], + publishedRaw: eventObject['published'], published: published, + updatedRaw: eventObject['updated'] ?? eventObject['published'], updated: eventObject['updated'] == null ? published - : DateTime.parse(eventObject['updated']).toLocal(), + : DateTime.parse(eventObject['updated']), category: eventObject['category']['term'], mediaID: eventObject.containsKey('content') ? int.parse(eventObject['content']['media_id']) @@ -147,12 +149,14 @@ extension EventsExtension on API { deviceID: int.parse((e['category']['term'] as String).split('/').first), title: e['title']['\$t'], + publishedRaw: e['published']['\$t'], published: e['published'] == null || e['published']['\$t'] == null - ? DateTime.now().toLocal() - : DateTime.parse(e['published']['\$t']).toLocal(), + ? DateTime.now() + : DateTime.parse(e['published']['\$t']), + updatedRaw: e['updated']['\$t'] ?? e['published']['\$t'], updated: e['updated'] == null || e['updated']['\$t'] == null - ? DateTime.now().toLocal() - : DateTime.parse(e['updated']['\$t']).toLocal(), + ? DateTime.now() + : DateTime.parse(e['updated']['\$t']), category: e['category']['term'], mediaID: !e.containsKey('content') ? null diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 299a2d89..0dbe28ff 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -480,15 +480,19 @@ "firstEventInitialPoint": "First event", "hourAgoInitialPoint": "1 hour ago", "@@APPLICATION": {}, + "appearance": "Appearance", "theme": "Theme", "themeDescription": "Change the appearance of the app", "system": "System", "light": "Light", "dark": "Dark", + "dateAndTime": "Date and Time", "dateFormat": "Date Format", "dateFormatDescription": "What format to use for displaying dates", "timeFormat": "Time Format", "timeFormatDescription": "What format to use for displaying time", + "convertToLocalTime": "Convert dates to the local timezone", + "convertToLocalTimeDescription": "This will affect the date and time displayed in the app. This is useful when you are in a different timezone than the server. When disabled, the server timezone will be used.", "@@PRIVACY_AND_SECURITY": {}, "privacyAndSecurity": "Privacy and Security", "allowDataCollection": "Allow Bluecherry to collect usage data", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6007bb65..453dd559 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -456,15 +456,19 @@ "firstEventInitialPoint": "First event", "hourAgoInitialPoint": "1 hour ago", "@@APPLICATION": {}, + "appearance": "Appearance", "theme": "Thème", "themeDescription": "Modifier l'apparence de l'application", "system": "Système", "light": "Clair", "dark": "Sombre", + "dateAndTime": "Date and Time", "dateFormat": "Format de la date", "dateFormatDescription": "What format to use for displaying dates", "timeFormat": "Format de l'heure", "timeFormatDescription": "What format to use for displaying time", + "convertToLocalTime": "Convert dates to the local timezone", + "convertToLocalTimeDescription": "Convert all dates to the local timezone. This will affect the date and time displayed in the app. This is useful when you are in a different timezone than the server.", "@@PRIVACY_AND_SECURITY": {}, "privacyAndSecurity": "Privacy and Security", "allowDataCollection": "Allow Bluecherry to collect usage data", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 754f634a..9bd4ee3b 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -480,15 +480,19 @@ "firstEventInitialPoint": "First event", "hourAgoInitialPoint": "1 hour ago", "@@APPLICATION": {}, + "appearance": "Appearance", "theme": "Motyw", "themeDescription": "Zmień wygląd aplikacji", "system": "Systemowy", "light": "Jasny", "dark": "Ciemny", + "dateAndTime": "Date and Time", "dateFormat": "Format daty", "dateFormatDescription": "What format to use for displaying dates", "timeFormat": "Format czasu", "timeFormatDescription": "What format to use for displaying time", + "convertToLocalTime": "Convert dates to the local timezone", + "convertToLocalTimeDescription": "This will affect the date and time displayed in the app. This is useful when you are in a different timezone than the server. When disabled, the server timezone will be used.", "@@PRIVACY_AND_SECURITY": {}, "privacyAndSecurity": "Privacy and Security", "allowDataCollection": "Allow Bluecherry to collect usage data", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b1bfe1a9..1fbf1cc9 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -480,15 +480,19 @@ "firstEventInitialPoint": "Primeiro evento", "hourAgoInitialPoint": "1 hora atrás", "@@APPLICATION": {}, + "appearance": "Appearance", "theme": "Aparência", "themeDescription": "Mude a aparência do aplicativo", "system": "Padrão do Sistema", "light": "Claro", "dark": "Escuro", + "dateAndTime": "Date and Time", "dateFormat": "Formato da Data", "dateFormatDescription": "Qual formato usar para exibir datas", "timeFormat": "Formato de Hora", "timeFormatDescription": "Qual formato usar para exibir horas", + "convertToLocalTime": "Convert dates to the local timezone", + "convertToLocalTimeDescription": "This will affect the date and time displayed in the app. This is useful when you are in a different timezone than the server. When disabled, the server timezone will be used.", "@@PRIVACY_AND_SECURITY": {}, "privacyAndSecurity": "Privacidade e Segurança", "allowDataCollection": "Permitir que Bluecherry colete dados de uso", diff --git a/lib/models/event.dart b/lib/models/event.dart index 191e8661..1760fbd6 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -29,7 +29,9 @@ class Event { final int id; final int deviceID; final String title; + final String publishedRaw; final DateTime published; + final String updatedRaw; final DateTime updated; final String? category; final int? mediaID; @@ -40,7 +42,9 @@ class Event { required this.id, required this.deviceID, required this.title, + required this.publishedRaw, required this.published, + required this.updatedRaw, required this.updated, required this.category, required this.mediaID, @@ -52,13 +56,17 @@ class Event { this.id = 1, this.deviceID = 1, this.title = '', + String? publishedRaw, DateTime? published, + String? updatedRaw, DateTime? updated, this.category, this.mediaID, this.mediaURL, }) : server = server ?? ServersProvider.instance.servers.first, + publishedRaw = publishedRaw ?? DateTime.now().toIso8601String(), published = published ?? DateTime.now(), + updatedRaw = updatedRaw ?? DateTime.now().toIso8601String(), updated = updated ?? DateTime.now(); String get deviceName { @@ -86,7 +94,9 @@ class Event { other.id == id && other.deviceID == deviceID && other.title == title && + other.publishedRaw == publishedRaw && other.published == published && + other.updatedRaw == updatedRaw && other.updated == updated && other.category == category && other.mediaID == mediaID && @@ -99,7 +109,9 @@ class Event { id.hashCode ^ deviceID.hashCode ^ title.hashCode ^ + publishedRaw.hashCode ^ published.hashCode ^ + updatedRaw.hashCode ^ updated.hashCode ^ category.hashCode ^ mediaID.hashCode ^ @@ -107,31 +119,8 @@ class Event { } @override - String toString() => - 'Event($id, $deviceID, $title, $published, $updated, $category, $mediaID, $mediaURL)'; - - Event copyWith( - Server? server, - int? id, - int? deviceID, - String? title, - DateTime? published, - DateTime? updated, - String? category, - int? mediaID, - Uri? mediaURL, - ) { - return Event( - server: server ?? this.server, - deviceID: deviceID ?? this.deviceID, - id: id ?? this.id, - title: title ?? this.title, - published: published ?? this.published, - updated: updated ?? this.updated, - category: category ?? this.category, - mediaID: mediaID ?? this.mediaID, - mediaURL: mediaURL ?? this.mediaURL, - ); + String toString() { + return 'Event(server: $server, id: $id, deviceID: $deviceID, title: $title, publishedRaw: $publishedRaw, published: $published, updatedRaw: $updatedRaw, updated: $updated, category: $category, mediaID: $mediaID, mediaURL: $mediaURL)'; } Map toJson() => { @@ -152,7 +141,9 @@ class Event { deviceID: json['deviceID'], id: json['id'], title: json['title'], + publishedRaw: json['published'], published: DateTime.parse(json['published']), + updatedRaw: json['updated'], updated: DateTime.parse(json['updated']), category: json['category'], mediaID: json['mediaID'], @@ -210,6 +201,34 @@ class Event { return EventType.unknown; } } + + Event copyWith({ + Server? server, + int? id, + int? deviceID, + String? title, + String? publishedRaw, + DateTime? published, + String? updatedRaw, + DateTime? updated, + String? category, + int? mediaID, + Uri? mediaURL, + }) { + return Event( + server: server ?? this.server, + id: id ?? this.id, + deviceID: deviceID ?? this.deviceID, + title: title ?? this.title, + publishedRaw: publishedRaw ?? this.publishedRaw, + published: published ?? this.published, + updatedRaw: updatedRaw ?? this.updatedRaw, + updated: updated ?? this.updated, + category: category ?? this.category, + mediaID: mediaID ?? this.mediaID, + mediaURL: mediaURL ?? this.mediaURL, + ); + } } enum EventPriority { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index fa8904df..49ede215 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -55,6 +55,10 @@ class _SettingsOption { }); } + T call() { + return value; + } + final T? min; final T? max; @@ -127,6 +131,11 @@ class _SettingsOption { _value = (await getDefault?.call()) ?? def; } } + + @override + String toString() { + return 'SettingsOption<$T>($key: $_value)'; + } } class SettingsProvider extends UnityProvider { @@ -292,6 +301,10 @@ class SettingsProvider extends UnityProvider { def: DateFormat('hh:mm a'), key: 'application.time_format', ); + final kConvertTimeToLocalTimezone = _SettingsOption( + def: false, + key: 'application.convert_time_to_local_timezone', + ); // Window final kLaunchAppOnStartup = _SettingsOption( @@ -417,6 +430,7 @@ class SettingsProvider extends UnityProvider { kLanguageCode.loadData(data), kDateFormat.loadData(data), kTimeFormat.loadData(data), + kConvertTimeToLocalTimezone.loadData(data), kLaunchAppOnStartup.loadData(data), kMinimizeToTray.loadData(data), kAnimationsEnabled.loadData(data), @@ -486,6 +500,8 @@ class SettingsProvider extends UnityProvider { kLanguageCode.key: kLanguageCode.saveAs(kLanguageCode.value), kDateFormat.key: kDateFormat.saveAs(kDateFormat.value), kTimeFormat.key: kTimeFormat.saveAs(kTimeFormat.value), + kConvertTimeToLocalTimezone.key: kConvertTimeToLocalTimezone + .saveAs(kConvertTimeToLocalTimezone.value), kLaunchAppOnStartup.key: kLaunchAppOnStartup.saveAs(kLaunchAppOnStartup.value), kMinimizeToTray.key: kMinimizeToTray.saveAs(kMinimizeToTray.value), @@ -519,24 +535,6 @@ class SettingsProvider extends UnityProvider { save(); } - /// Formats the date according to the current [dateFormat]. - /// - /// [toLocal] defines if the date will be converted to local time. Defaults to `true` - String formatDate(DateTime date, {bool toLocal = false}) { - if (toLocal) date = date.toLocal(); - - return kDateFormat.value.format(date); - } - - /// Formats the date according to the current [dateFormat]. - /// - /// [toLocal] defines if the date will be converted to local time. Defaults to `true` - String formatTime(DateTime time, {bool toLocal = false}) { - if (toLocal) time = time.toLocal(); - - return kTimeFormat.value.format(time); - } - void toggleCycling() { kLayoutCycleEnabled.value = !kLayoutCycleEnabled.value; save(); diff --git a/lib/screens/downloads/downloads_manager.dart b/lib/screens/downloads/downloads_manager.dart index 56274823..ff84a582 100644 --- a/lib/screens/downloads/downloads_manager.dart +++ b/lib/screens/downloads/downloads_manager.dart @@ -24,6 +24,7 @@ import 'package:bluecherry_client/providers/downloads_provider.dart'; import 'package:bluecherry_client/providers/home_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/screens/downloads/indicators.dart'; +import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:bluecherry_client/utils/methods.dart'; import 'package:bluecherry_client/utils/theme.dart'; @@ -155,7 +156,7 @@ class _DownloadTileState extends State { final settings = context.watch(); final eventType = widget.event.type.locale(context).uppercaseFirst; - final at = settings.formatDate(widget.event.published); + final at = settings.formatRawDateAndTime(widget.event.publishedRaw); final shape = RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), diff --git a/lib/screens/events_browser/events_screen.dart b/lib/screens/events_browser/events_screen.dart index 8b1e291c..9bfd7dc1 100644 --- a/lib/screens/events_browser/events_screen.dart +++ b/lib/screens/events_browser/events_screen.dart @@ -32,6 +32,7 @@ import 'package:bluecherry_client/screens/events_browser/filter.dart'; import 'package:bluecherry_client/screens/events_browser/sidebar.dart'; import 'package:bluecherry_client/screens/players/event_player_desktop.dart'; import 'package:bluecherry_client/utils/constants.dart'; +import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:bluecherry_client/utils/methods.dart'; import 'package:bluecherry_client/widgets/desktop_buttons.dart'; diff --git a/lib/screens/events_browser/events_screen_desktop.dart b/lib/screens/events_browser/events_screen_desktop.dart index bd5d12c5..8e5eba0b 100644 --- a/lib/screens/events_browser/events_screen_desktop.dart +++ b/lib/screens/events_browser/events_screen_desktop.dart @@ -107,7 +107,7 @@ class EventsScreenDesktop extends StatelessWidget { ), _buildTilePart( child: Text( - '${settings.formatDate(event.updated)} ${settings.formatTime(event.updated).toUpperCase()}', + settings.formatRawDateAndTime(event.publishedRaw), ), flex: 2, ), diff --git a/lib/screens/layouts/video_status_label.dart b/lib/screens/layouts/video_status_label.dart index 7f9cc39f..b4f46386 100644 --- a/lib/screens/layouts/video_status_label.dart +++ b/lib/screens/layouts/video_status_label.dart @@ -20,6 +20,7 @@ import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/event.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; diff --git a/lib/screens/players/event_player_desktop.dart b/lib/screens/players/event_player_desktop.dart index 626ba0cc..2997b512 100644 --- a/lib/screens/players/event_player_desktop.dart +++ b/lib/screens/players/event_player_desktop.dart @@ -27,6 +27,7 @@ import 'package:bluecherry_client/providers/downloads_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/screens/downloads/indicators.dart'; import 'package:bluecherry_client/screens/layouts/video_status_label.dart'; +import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:bluecherry_client/widgets/collapsable_sidebar.dart'; import 'package:bluecherry_client/widgets/desktop_buttons.dart'; @@ -172,10 +173,7 @@ class _EventPlayerDesktopState extends State { data: SliderThemeData(trackShape: _CustomTrackShape()), child: Material( child: Column(children: [ - WindowButtons( - title: title, - showNavigator: false, - ), + WindowButtons(title: title, showNavigator: false), Expanded( child: Row(children: [ Expanded( @@ -245,8 +243,11 @@ class _EventPlayerDesktopState extends State { snapshot.data ?? videoController.currentPos; return Row(children: [ Text( - DateFormat.Hms() - .format(currentEvent.published.add(pos)), + settings.formatTimeRaw( + currentEvent.publishedRaw, + offset: pos, + pattern: DateFormat.Hms(), + ), ), padd, Expanded( @@ -296,8 +297,10 @@ class _EventPlayerDesktopState extends State { ), padd, Text( - DateFormat.Hms().format( - currentEvent.published.add(duration), + settings.formatTimeRaw( + currentEvent.publishedRaw, + offset: duration, + pattern: DateFormat.Hms(), ), ), padd, @@ -490,7 +493,7 @@ class EventTile extends StatelessWidget { final loc = AppLocalizations.of(context); final eventType = event.type.locale(context).uppercaseFirst; - final at = settings.formatDate(event.published); + final at = settings.formatRawDateAndTime(event.publishedRaw); return SizedBox( width: double.infinity, diff --git a/lib/screens/settings/application.dart b/lib/screens/settings/application.dart index 706a9b66..b464b9f4 100644 --- a/lib/screens/settings/application.dart +++ b/lib/screens/settings/application.dart @@ -36,7 +36,11 @@ class ApplicationSettings extends StatelessWidget { final loc = AppLocalizations.of(context); final theme = Theme.of(context); final settings = context.watch(); - return ListView(padding: DesktopSettings.verticalPadding, children: [ + return ListView(children: [ + SubHeader( + loc.appearance, + padding: DesktopSettings.horizontalPadding, + ), OptionsChooserTile( title: loc.theme, description: loc.themeDescription, @@ -66,8 +70,29 @@ class ApplicationSettings extends StatelessWidget { }, ), const LanguageSection(), + SubHeader( + loc.dateAndTime, + padding: DesktopSettings.horizontalPadding, + ), const DateFormatSection(), const TimeFormatSection(), + CheckboxListTile.adaptive( + value: settings.kConvertTimeToLocalTimezone.value, + onChanged: (v) { + if (v != null) { + settings.kConvertTimeToLocalTimezone.value = v; + } + }, + contentPadding: DesktopSettings.horizontalPadding, + secondary: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.history_toggle_off), + ), + title: Text(loc.convertToLocalTime), + subtitle: Text(loc.convertToLocalTimeDescription), + isThreeLine: true, + ), if (settings.kShowDebugInfo.value) ...[ const SubHeader('Window'), CheckboxListTile.adaptive( @@ -250,7 +275,7 @@ class DateFormatSection extends StatelessWidget { title: loc.dateFormat, description: loc.dateFormatDescription, icon: Icons.calendar_month, - value: '', + value: settings.kDateFormat.value.pattern, values: formats.map((format) { return Option( value: format.pattern, @@ -279,7 +304,7 @@ class TimeFormatSection extends StatelessWidget { title: loc.timeFormat, description: loc.timeFormatDescription, icon: Icons.hourglass_empty, - value: '', + value: settings.kTimeFormat.value.pattern, values: patterns.map((pattern) { return Option( value: pattern.pattern, diff --git a/lib/screens/settings/general.dart b/lib/screens/settings/general.dart index f243f15b..f9e919fc 100644 --- a/lib/screens/settings/general.dart +++ b/lib/screens/settings/general.dart @@ -20,6 +20,7 @@ import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/screens/settings/settings_desktop.dart'; import 'package:bluecherry_client/screens/settings/shared/options_chooser_tile.dart'; +import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:bluecherry_client/widgets/misc.dart'; import 'package:flutter/material.dart'; diff --git a/lib/utils/date.dart b/lib/utils/date.dart new file mode 100644 index 00000000..068fe8a0 --- /dev/null +++ b/lib/utils/date.dart @@ -0,0 +1,81 @@ +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +/// Convert a date string to a DateTime object, considering the timezone offset. +DateTime timezoneAwareDate(String originalDateString) { + final originalDateTime = DateTime.parse(originalDateString); + + try { + final offsetString = originalDateString.split('-').last; + final parts = offsetString.split(':'); + + // Convert hours and minutes strings to integers + final hours = int.parse(parts[0]); + final minutes = int.parse(parts[1]); + + // Create a Duration object based on the offset sign + final offset = Duration(hours: -hours, minutes: -minutes); + + return originalDateTime.add(offset); + } catch (e) { + debugPrint('Failed to parse date string: $originalDateString'); + return originalDateTime; + } +} + +extension DateSettingsExtension on SettingsProvider { + /// Formats the date according to the current [dateFormat]. + /// + /// [toLocal] defines if the date will be converted to local time. Defaults to `true` + String formatDate(DateTime date) { + if (kConvertTimeToLocalTimezone.value) date = date.toLocal(); + + return kDateFormat.value.format(date); + } + + String formatRawTime(String rawDate) { + return kTimeFormat.value.format( + kConvertTimeToLocalTimezone.value + ? DateTime.parse(rawDate).toLocal() + : timezoneAwareDate(rawDate), + ); + } + + /// Formats the date according to the current [dateFormat]. + /// + /// [toLocal] defines if the date will be converted to local time. Defaults to `true` + String formatTime( + DateTime time, { + DateFormat? pattern, + bool withSeconds = false, + bool? toLocal, + }) { + if (toLocal ?? kConvertTimeToLocalTimezone()) time = time.toLocal(); + + pattern ??= DateFormat(kTimeFormat.value.pattern); + + if (withSeconds) { + pattern = pattern.add_s(); + } + + return pattern.format(time); + } + + String formatTimeRaw( + String rawTime, { + DateFormat? pattern, + Duration offset = Duration.zero, + }) { + return formatTime( + timezoneAwareDate(rawTime).add(offset), + pattern: pattern, + ); + } + + String formatRawDateAndTime(String rawDateTime) { + final date = formatDate(DateTime.parse(rawDateTime)); + final time = formatRawTime(rawDateTime).toUpperCase(); + return '$date $time'; + } +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 49aaeb9b..4c331974 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -132,7 +132,8 @@ extension ServerExtension on List { extension DateTimeExtension on DateTime { /// Returns true if this date is between [first] and [second] /// - /// If [allowSameMoment] is true, then the date can be equal to [first] or [second]. + /// If [allowSameMoment] is true, then the date can be equal to [first] or + /// [second]. bool isInBetween( DateTime first, DateTime second, { @@ -142,9 +143,7 @@ extension DateTimeExtension on DateTime { toLocal().isBefore(second.toLocal()); if (allowSameMoment) return isBetween; - return isBetween || - toLocal().isAtSameMomentAs(first.toLocal()) || - toLocal().isAtSameMomentAs(second.toLocal()); + return isBetween || isAtSameMomentAs(first) || isAtSameMomentAs(second); } }