fixes timestamping across timezones #3

Merged
kfj001 merged 1 commits from fix_time into main 2026-05-14 23:57:39 -07:00
6 changed files with 225 additions and 7 deletions
Showing only changes of commit d32720a2e0 - Show all commits
+3 -1
View File
@@ -112,19 +112,21 @@ class LiveInfoPanel extends StatelessWidget {
ShowCard( ShowCard(
label: 'On Air Show', label: 'On Air Show',
show: currentShow, show: currentShow,
sourceTimezone: snapshot?.timezone,
titleColor: themeColors.statusText, titleColor: themeColors.statusText,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
ShowCard( ShowCard(
label: 'Up Next Show', label: 'Up Next Show',
show: nextShow, show: nextShow,
sourceTimezone: snapshot?.timezone,
titleColor: themeColors.statusText, titleColor: themeColors.statusText,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
liveInfo.lastUpdatedAt == null liveInfo.lastUpdatedAt == null
? 'Waiting for API response...' ? '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, style: Theme.of(context).textTheme.labelSmall,
), ),
], ],
+2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:just_audio_background/just_audio_background.dart'; import 'package:just_audio_background/just_audio_background.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'app_theme_business_object.dart'; import 'app_theme_business_object.dart';
import 'casting_business_object.dart'; import 'casting_business_object.dart';
import 'live_info_business_object.dart'; import 'live_info_business_object.dart';
@@ -10,6 +11,7 @@ import 'streaming_audio_business_object.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
tz.initializeTimeZones();
await JustAudioBackground.init( await JustAudioBackground.init(
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel', androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: 'KRYZ Audio Playback', androidNotificationChannelName: 'KRYZ Audio Playback',
+6 -1
View File
@@ -8,11 +8,13 @@ class ShowCard extends StatelessWidget {
super.key, super.key,
required this.label, required this.label,
required this.show, required this.show,
required this.sourceTimezone,
required this.titleColor, required this.titleColor,
}); });
final String label; final String label;
final LiveInfoShowInfo? show; final LiveInfoShowInfo? show;
final String? sourceTimezone;
final Color titleColor; final Color titleColor;
@override @override
@@ -40,7 +42,10 @@ class ShowCard extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
formatStartingTime(show?.starts), formatStartingTime(
show?.starts,
sourceTimezone: sourceTimezone,
),
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
], ],
+107 -5
View File
@@ -1,4 +1,5 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:timezone/timezone.dart' as tz;
// Source - https://stackoverflow.com/a/54775297 // Source - https://stackoverflow.com/a/54775297
// Posted by diegoveloper, modified by community. See post 'Timeline' for change history // 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) { String formatTimeWindow(String? starts, String? ends) {
if (starts != null && ends != null) { if (starts != null && ends != null) {
DateTime startDt = DateTime.parse(starts); DateTime? startDt = _tryParseServerDateTime(starts);
DateTime endDt = DateTime.parse(ends); DateTime? endDt = _tryParseServerDateTime(ends);
if (startDt == null || endDt == null) {
return '--:--';
}
Duration songLength = endDt.difference(startDt); Duration songLength = endDt.difference(startDt);
@@ -25,10 +29,108 @@ String formatTimeWindow(String? starts, String? ends) {
return "--:--"; return "--:--";
} }
String formatStartingTime(String? startingTime) { String formatStartingTime(String? startingTime, {String? sourceTimezone}) {
if (startingTime != null) { if (startingTime != null) {
DateTime dtStartTime = DateTime.parse(startingTime); final DateTime? localStart = _serverTimeToLocal(
return DateFormat('jm').format(dtStartTime); startingTime,
sourceTimezone: sourceTimezone,
);
if (localStart == null) {
return '--:--';
}
return DateFormat('jm').format(localStart);
} }
return "--:--"; 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,
);
}
+1
View File
@@ -40,6 +40,7 @@ dependencies:
webview_flutter: ^4.13.0 webview_flutter: ^4.13.0
http: ^1.5.0 http: ^1.5.0
intl: ^0.20.2 intl: ^0.20.2
timezone: ^0.10.1
dev_dependencies: dev_dependencies:
flutter_name_manager: ^1.0.0 flutter_name_manager: ^1.0.0
+106
View File
@@ -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'), '--:--');
});
});
}