From d32720a2e0fe6f99df4f2fb69bce8f007264091c Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 14 May 2026 22:51:04 -0700 Subject: [PATCH] fixes timestamping across timezones --- lib/live_info_panel.dart | 4 +- lib/main.dart | 2 + lib/show_card.dart | 7 ++- lib/time_formatter.dart | 112 ++++++++++++++++++++++++++++++++-- pubspec.yaml | 1 + test/time_formatter_test.dart | 106 ++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 test/time_formatter_test.dart diff --git a/lib/live_info_panel.dart b/lib/live_info_panel.dart index 2803ea6..6393af9 100644 --- a/lib/live_info_panel.dart +++ b/lib/live_info_panel.dart @@ -112,19 +112,21 @@ class LiveInfoPanel extends StatelessWidget { ShowCard( label: 'On Air Show', show: currentShow, + sourceTimezone: snapshot?.timezone, titleColor: themeColors.statusText, ), const SizedBox(height: 6), ShowCard( label: 'Up Next Show', show: nextShow, + sourceTimezone: snapshot?.timezone, titleColor: themeColors.statusText, ), const SizedBox(height: 6), Text( liveInfo.lastUpdatedAt == null ? 'Waiting for API response...' - : 'Last update: ${DateFormat.yMd().format(liveInfo.lastUpdatedAt!.toLocal())} ${DateFormat.jm().format(liveInfo.lastUpdatedAt!.toLocal())} • ${snapshot?.timezone ?? '--'}', + : 'Last update (Local): ${DateFormat.yMd().format(liveInfo.lastUpdatedAt!.toLocal())} ${DateFormat.jm().format(liveInfo.lastUpdatedAt!.toLocal())}', style: Theme.of(context).textTheme.labelSmall, ), ], diff --git a/lib/main.dart b/lib/main.dart index f35b924..db2971a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio_background/just_audio_background.dart'; import 'package:flutter/services.dart'; +import 'package:timezone/data/latest.dart' as tz; import 'app_theme_business_object.dart'; import 'casting_business_object.dart'; import 'live_info_business_object.dart'; @@ -10,6 +11,7 @@ import 'streaming_audio_business_object.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + tz.initializeTimeZones(); await JustAudioBackground.init( androidNotificationChannelId: 'com.kryzgoflutter.audio.channel', androidNotificationChannelName: 'KRYZ Audio Playback', diff --git a/lib/show_card.dart b/lib/show_card.dart index 1d69104..1a94805 100644 --- a/lib/show_card.dart +++ b/lib/show_card.dart @@ -8,11 +8,13 @@ class ShowCard extends StatelessWidget { super.key, required this.label, required this.show, + required this.sourceTimezone, required this.titleColor, }); final String label; final LiveInfoShowInfo? show; + final String? sourceTimezone; final Color titleColor; @override @@ -40,7 +42,10 @@ class ShowCard extends StatelessWidget { ), const SizedBox(width: 8), Text( - formatStartingTime(show?.starts), + formatStartingTime( + show?.starts, + sourceTimezone: sourceTimezone, + ), style: Theme.of(context).textTheme.labelSmall, ), ], diff --git a/lib/time_formatter.dart b/lib/time_formatter.dart index c091569..7260be6 100644 --- a/lib/time_formatter.dart +++ b/lib/time_formatter.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'package:timezone/timezone.dart' as tz; // Source - https://stackoverflow.com/a/54775297 // Posted by diegoveloper, modified by community. See post 'Timeline' for change history @@ -14,8 +15,11 @@ String _printDuration(Duration duration) { String formatTimeWindow(String? starts, String? ends) { if (starts != null && ends != null) { - DateTime startDt = DateTime.parse(starts); - DateTime endDt = DateTime.parse(ends); + DateTime? startDt = _tryParseServerDateTime(starts); + DateTime? endDt = _tryParseServerDateTime(ends); + if (startDt == null || endDt == null) { + return '--:--'; + } Duration songLength = endDt.difference(startDt); @@ -25,10 +29,108 @@ String formatTimeWindow(String? starts, String? ends) { return "--:--"; } -String formatStartingTime(String? startingTime) { +String formatStartingTime(String? startingTime, {String? sourceTimezone}) { if (startingTime != null) { - DateTime dtStartTime = DateTime.parse(startingTime); - return DateFormat('jm').format(dtStartTime); + final DateTime? localStart = _serverTimeToLocal( + startingTime, + sourceTimezone: sourceTimezone, + ); + if (localStart == null) { + return '--:--'; + } + return DateFormat('jm').format(localStart); } return "--:--"; } + +DateTime? _serverTimeToLocal(String rawTime, {String? sourceTimezone}) { + final DateTime? parsed = _tryParseServerDateTime(rawTime); + if (parsed == null) { + return null; + } + + if (_hasExplicitTimezone(rawTime)) { + return parsed.toLocal(); + } + + // API timestamps are wall-clock values in the API timezone field. + if (sourceTimezone == null || sourceTimezone.trim().isEmpty) { + return parsed; + } + + try { + final tz.Location sourceLocation = tz.getLocation(sourceTimezone.trim()); + final tz.TZDateTime sourceTime = tz.TZDateTime( + sourceLocation, + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + return DateTime.fromMillisecondsSinceEpoch( + sourceTime.millisecondsSinceEpoch, + isUtc: true, + ).toLocal(); + } catch (_) { + return parsed; + } +} + +bool _hasExplicitTimezone(String value) { + final String trimmed = value.trim(); + + if (trimmed.endsWith('Z') || trimmed.endsWith('z')) { + return true; + } + + final RegExp offsetPattern = RegExp(r'[+-]\d{2}:?\d{2}$'); + return offsetPattern.hasMatch(trimmed); +} + +DateTime? _tryParseServerDateTime(String value) { + final String trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + final DateTime? isoAttempt = DateTime.tryParse(trimmed); + if (isoAttempt != null) { + return isoAttempt; + } + + final RegExp serverDateTimePattern = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$', + ); + final RegExpMatch? match = serverDateTimePattern.firstMatch(trimmed); + if (match == null) { + return null; + } + + int parsePart(int group) => int.parse(match.group(group)!); + + final int year = parsePart(1); + final int month = parsePart(2); + final int day = parsePart(3); + final int hour = parsePart(4); + final int minute = parsePart(5); + final int second = parsePart(6); + + final String fraction = (match.group(7) ?? '').padRight(6, '0'); + final int microseconds = + fraction.isEmpty ? 0 : int.parse(fraction.substring(0, 6)); + + return DateTime( + year, + month, + day, + hour, + minute, + second, + microseconds ~/ 1000, + microseconds % 1000, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 0caba3d..ef89bf5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: webview_flutter: ^4.13.0 http: ^1.5.0 intl: ^0.20.2 + timezone: ^0.10.1 dev_dependencies: flutter_name_manager: ^1.0.0 diff --git a/test/time_formatter_test.dart b/test/time_formatter_test.dart new file mode 100644 index 0000000..49a0bff --- /dev/null +++ b/test/time_formatter_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:kryz_go_flutter/time_formatter.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +void main() { + setUpAll(() { + tz.initializeTimeZones(); + }); + + group('formatStartingTime', () { + test('returns local display from source timezone naive server time', () { + const String sourceTime = '2026-05-14 20:00:00'; + const String sourceZone = 'America/Los_Angeles'; + + final tz.TZDateTime source = tz.TZDateTime( + tz.getLocation(sourceZone), + 2026, + 5, + 14, + 20, + ); + final String expected = DateFormat('jm').format( + DateTime.fromMillisecondsSinceEpoch( + source.millisecondsSinceEpoch, + isUtc: true, + ).toLocal(), + ); + + expect( + formatStartingTime(sourceTime, sourceTimezone: sourceZone), + expected, + ); + }); + + test('falls back to parsed local time when timezone is invalid', () { + final DateTime parsed = DateTime.parse('2026-05-14 20:00:00'); + final String expected = DateFormat('jm').format(parsed); + + expect( + formatStartingTime( + '2026-05-14 20:00:00', + sourceTimezone: 'Invalid/Timezone', + ), + expected, + ); + }); + + test('returns placeholder for invalid input', () { + expect(formatStartingTime('not-a-time'), '--:--'); + expect(formatStartingTime(null), '--:--'); + }); + + test('does not double-convert timestamps with explicit UTC designator', () { + const String utcStamp = '2026-05-15T03:00:00Z'; + final String expected = DateFormat('jm').format(DateTime.parse(utcStamp).toLocal()); + + expect( + formatStartingTime( + utcStamp, + sourceTimezone: 'America/Los_Angeles', + ), + expected, + ); + }); + + test('keeps wall-clock time when local matches source zone for naive input', () { + const String sourceTime = '2026-05-14 20:00:00'; + const String sourceZone = 'America/Los_Angeles'; + + final tz.TZDateTime source = tz.TZDateTime( + tz.getLocation(sourceZone), + 2026, + 5, + 14, + 20, + ); + final String expected = DateFormat('jm').format( + DateTime.fromMillisecondsSinceEpoch( + source.millisecondsSinceEpoch, + isUtc: true, + ).toLocal(), + ); + + expect( + formatStartingTime(sourceTime, sourceTimezone: sourceZone), + expected, + ); + }); + }); + + group('formatTimeWindow', () { + test('returns mm:ss style duration from server times', () { + expect( + formatTimeWindow('2026-05-15 05:18:46.000000', '2026-05-15 05:23:21.000000'), + '04:35', + ); + }); + + test('returns placeholder for invalid windows', () { + expect(formatTimeWindow('nope', '2026-05-15 05:23:21.000000'), '--:--'); + expect(formatTimeWindow(null, '2026-05-15 05:23:21.000000'), '--:--'); + }); + }); +}