Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da0779cafb | ||
|
|
d32720a2e0 |
@@ -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,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
@@ -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
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'), '--:--');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user