import 'package:flutter/material.dart'; import '../api/api_client.dart'; import '../widgets/api_view.dart'; // Palette: pinks and purples on white. const _plum = Color(0xFF2B1148); // text const _muted = Color(0xFF7A6A90); // secondary text const _purple = Color(0xFF7B2FF7); const _pink = Color(0xFFF0368F); const _lilac = Color(0xFFF1E8FF); const _bg = Color(0xFFFBF8FF); const _grad = LinearGradient( colors: [_purple, _pink], begin: Alignment.topLeft, end: Alignment.bottomRight, ); const _months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /// Day and month for the date badge, from the plugin's `date_ts` (null if unknown). ({String day, String mon})? _badge(Map e) { final ts = e['date_ts']; if (ts is! int || ts <= 0) return null; final d = DateTime.fromMillisecondsSinceEpoch(ts * 1000); return (day: '${d.day}', mon: _months[d.month - 1]); } /// How long ago the event was added to the site, from the plugin's `published` field. ({String label, bool isNew})? _added(Map e) { final p = e['published']; final d = p is String ? DateTime.tryParse(p) : null; if (d == null) return null; final days = DateTime.now().difference(d).inDays; final label = days < 1 ? 'Added today' : days < 2 ? 'Added yesterday' : days < 30 ? 'Added $days days ago' : 'Added ${(days / 30).floor()} mo ago'; return (label: label, isNew: days < 7); } void _openEvent(BuildContext context, Map e) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => EventDetailScreen(id: e['id'] as int, name: '${e['name'] ?? ''}'), )); } class EventsScreen extends StatefulWidget { const EventsScreen({super.key}); @override State createState() => _EventsScreenState(); } class _EventsScreenState extends State { // Venue chips. Slugs match the WooCommerce product categories. final List> _venues = [ {'name': 'All venues', 'slug': ''}, {'name': 'SSE Arena Belfast', 'slug': 'sse-arena-concerts'}, {'name': '3Arena Dublin', 'slug': '3arena-concerts'}, {'name': 'Croke Park', 'slug': 'croke-park-concerts'}, {'name': 'Slane Castle', 'slug': 'slane-castle-concerts'}, {'name': 'Belsonic', 'slug': 'belsonic'}, ]; // 'just_announced' = newest first. Switch to 'upcoming' (soonest first) once dates are fixed. static const String _venueFilter = 'just_announced'; String _selectedVenueSlug = ''; Widget _title(String text, {String? sub}) => Padding( padding: const EdgeInsets.fromLTRB(20, 30, 20, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(text, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w800, color: _plum, letterSpacing: -0.4)), if (sub != null) Padding( padding: const EdgeInsets.only(top: 2), child: Text(sub, style: const TextStyle(fontSize: 13, color: _muted)), ), ], ), ); Widget _thumb(dynamic img, double size) => ClipRRect( borderRadius: BorderRadius.circular(14), child: SizedBox( width: size, height: size, child: (img is String && img.isNotEmpty) ? Image.network(img, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const ColoredBox(color: _lilac)) : const ColoredBox(color: _lilac, child: Icon(Icons.directions_bus, color: _purple)), ), ); /// Poster card for the horizontal Upcoming row. Widget _posterCard(BuildContext context, Map e) { final img = e['image']; final b = _badge(e); return GestureDetector( onTap: () => _openEvent(context, e), child: Container( width: 168, margin: const EdgeInsets.only(right: 12), decoration: BoxDecoration(color: _plum, borderRadius: BorderRadius.circular(22)), clipBehavior: Clip.antiAlias, child: Stack( fit: StackFit.expand, children: [ if (img is String && img.isNotEmpty) Image.network(img, fit: BoxFit.cover), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x002B1148), Color(0xF22B1148)], stops: [0.3, 1], ), ), ), if (b != null) Positioned( top: 10, left: 10, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Column(children: [ Text(b.day, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w900, height: 1, color: _purple)), Text(b.mon, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: _pink)), ]), ), ), Positioned( left: 12, right: 12, bottom: 12, child: Text('${e['name'] ?? ''}', maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14, height: 1.2)), ), ], ), ), ); } /// List row used by Just announced and the venue list. Widget _eventTile(BuildContext context, Map e, {bool showAdded = false}) { final venue = '${e['venue'] ?? ''}'; final date = '${e['date'] ?? ''}'; final price = e['price_from']; final added = showAdded ? _added(e) : null; return Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFEBDDFB)), boxShadow: const [ BoxShadow(color: Color(0x0F7B2FF7), blurRadius: 14, offset: Offset(0, 5)), ], ), child: InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _openEvent(context, e), child: Padding( padding: const EdgeInsets.all(12), child: Row(children: [ _thumb(e['image'], 80), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (added != null && added.isNew) Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( gradient: _grad, borderRadius: BorderRadius.circular(8)), child: const Text('New', style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w800)), ), Text('${e['name'] ?? ''}', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontWeight: FontWeight.w700, fontSize: 14.5, height: 1.25, color: _plum)), const SizedBox(height: 6), if (venue.isNotEmpty) Row(children: [ const Icon(Icons.location_on_outlined, size: 14, color: _purple), const SizedBox(width: 3), Expanded( child: Text(venue, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5, color: _muted)), ), ]), if (date.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2), child: Row(children: [ const Icon(Icons.event_outlined, size: 14, color: _purple), const SizedBox(width: 3), Text(date, style: const TextStyle(fontSize: 12.5, color: _muted)), ]), ), if (added != null) Padding( padding: const EdgeInsets.only(top: 2), child: Text(added.label, style: const TextStyle( fontSize: 11.5, color: _pink, fontWeight: FontWeight.w600)), ), ], ), ), if (price != null) ...[ const SizedBox(width: 10), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration(gradient: _grad, borderRadius: BorderRadius.circular(14)), child: Column(children: [ const Text('from', style: TextStyle(fontSize: 10, color: Colors.white70)), Text(money(price), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: Colors.white)), ]), ), ], ]), ), ), ); } Widget _eventList(dynamic body, String empty, {bool showAdded = false}) { final events = (body is Map ? body['events'] : null) as List? ?? []; if (events.isEmpty) { return Padding( padding: const EdgeInsets.all(28), child: Center(child: Text(empty, style: const TextStyle(color: _muted))), ); } return Column(children: [ for (final item in events) _eventTile(context, Map.from(item as Map), showAdded: showAdded), ]); } Widget _venueChip(Map v) { final selected = _selectedVenueSlug == v['slug']; return GestureDetector( onTap: () => setState(() => _selectedVenueSlug = v['slug']!), child: AnimatedContainer( duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(horizontal: 16), alignment: Alignment.center, decoration: BoxDecoration( gradient: selected ? _grad : null, color: selected ? null : Colors.white, borderRadius: BorderRadius.circular(30), border: Border.all(color: selected ? Colors.transparent : const Color(0xFFEBDDFB)), ), child: Text(v['name']!, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 13.5, color: selected ? Colors.white : _plum)), ), ); } @override Widget build(BuildContext context) { return ColoredBox( color: _bg, child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ // Hero Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(24, 28, 24, 26), decoration: BoxDecoration(gradient: _grad, borderRadius: BorderRadius.circular(28)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Get to the gig.', style: TextStyle( color: Colors.white, fontSize: 34, fontWeight: FontWeight.w900, height: 1.05, letterSpacing: -1)), const SizedBox(height: 8), const Text('Coach travel to concerts across Ireland and the UK.', style: TextStyle(color: Color(0xFFFFE3F1), fontSize: 15, height: 1.35)), const SizedBox(height: 20), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(30)), child: const Text('Buses to Concerts', style: TextStyle(fontWeight: FontWeight.w800, color: _purple, fontSize: 13)), ), ], ), ), // Upcoming _title('Coming up', sub: 'The next concerts, soonest first'), SizedBox( height: 236, child: ApiView( request: () => ApiClient.instance .request('GET', '/events', query: {'per_page': '10', 'filter': 'upcoming'}), builder: (context, body) { final events = (body is Map ? body['events'] : null) as List? ?? []; if (events.isEmpty) { return const Center( child: Text('No upcoming concerts yet', style: TextStyle(color: _muted))); } return ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: events.length, itemBuilder: (context, i) => _posterCard(context, Map.from(events[i] as Map)), ); }, ), ), // Latest releases _title('Just announced', sub: 'Newest concerts added to the site'), ApiView( request: () => ApiClient.instance .request('GET', '/events', query: {'per_page': '10', 'filter': 'just_announced'}), builder: (context, body) => _eventList(body, 'No new concerts right now', showAdded: true), ), // Venues _title('Browse by venue'), SizedBox( height: 44, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _venues.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, i) => _venueChip(_venues[i]), ), ), const SizedBox(height: 16), ApiView( key: ValueKey(_selectedVenueSlug), request: () => ApiClient.instance.request('GET', '/events', query: { 'per_page': '30', 'filter': _venueFilter, if (_selectedVenueSlug.isNotEmpty) 'category': _selectedVenueSlug, }), builder: (context, body) => _eventList(body, 'No concerts listed for this venue yet'), ), // Help banner Container( margin: const EdgeInsets.fromLTRB(16, 18, 16, 28), decoration: BoxDecoration(gradient: _grad, borderRadius: BorderRadius.circular(22)), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), leading: const CircleAvatar( backgroundColor: Colors.white, child: Icon(Icons.chat_bubble, color: _pink, size: 20)), title: const Text('Need help or a private hire?', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800)), subtitle: const Text('Message us on WhatsApp', style: TextStyle(color: Color(0xFFFFE3F1))), trailing: const Icon(Icons.arrow_forward_ios, color: Colors.white, size: 16), onTap: () { // WhatsApp launch action }, ), ), ], ), ); } } const _days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; String _prettyDate(String raw) { final d = DateTime.tryParse(raw); if (d == null) return raw; return '${_days[d.weekday - 1]} ${d.day} ${_months[d.month - 1]} ${d.year}'; } /// Splits "16:50 Dundalk Depart Bus Stop @ J18 ..." into time, town and stop. ({String time, String town, String stop}) _parsePickup(String label) { final m = RegExp(r'^(\d{1,2}:\d{2})\s+(.*?)\s+Depart\s+(.*)$', caseSensitive: false) .firstMatch(label); if (m == null) return (time: '', town: label, stop: ''); var stop = m.group(3)!.replaceFirst(RegExp(r'^Bus Stop\s*@\s*', caseSensitive: false), ''); stop = stop.replaceAll(RegExp(r'\s*Bus Stop\s*@\s*', caseSensitive: false), ', '); return (time: m.group(1)!, town: m.group(2)!, stop: stop); } class EventDetailScreen extends StatelessWidget { const EventDetailScreen({super.key, required this.id, required this.name}); final int id; final String name; Widget _heading(String text) => Padding( padding: const EdgeInsets.fromLTRB(0, 28, 0, 12), child: Text(text, style: const TextStyle( fontSize: 19, fontWeight: FontWeight.w800, color: _plum, letterSpacing: -0.3)), ); Widget _chip(IconData icon, String text) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration(color: _lilac, borderRadius: BorderRadius.circular(30)), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 15, color: _purple), const SizedBox(width: 6), Flexible( child: Text(text, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: _plum)), ), ]), ); Widget _card({required Widget child}) => Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), border: Border.all(color: const Color(0xFFEBDDFB)), ), child: child, ); Widget _pickup(Map v) { final p = _parsePickup('${v['label'] ?? ''}'); final notes = [v['route'], v['description']] .where((s) => s != null && '$s'.trim().isNotEmpty) .join('\n'); return _card( child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (p.time.isNotEmpty) Container( width: 62, padding: const EdgeInsets.symmetric(vertical: 10), alignment: Alignment.center, decoration: BoxDecoration(gradient: _grad, borderRadius: BorderRadius.circular(14)), child: Text(p.time, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15)), ), if (p.time.isNotEmpty) const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(p.town, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 15.5, color: _plum)), if (p.stop.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2), child: Text(p.stop, style: const TextStyle(fontSize: 13, color: _muted, height: 1.3)), ), if (notes.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 6), child: Text(notes, style: const TextStyle( fontSize: 12, color: _pink, fontWeight: FontWeight.w600, height: 1.3)), ), ]), ), const SizedBox(width: 10), Text(money(v['price']), style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: _purple)), ]), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _bg, appBar: AppBar( backgroundColor: _bg, foregroundColor: _plum, elevation: 0, scrolledUnderElevation: 0, title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), ), body: ApiView( request: () => ApiClient.instance.request('GET', '/events/$id'), builder: (context, body) { final m = body is Map ? body : {}; final summary = Map.from((m['summary'] as Map?) ?? {}); final data = (m['data'] as Map?) ?? {}; final product = (data['product'] as Map?) ?? {}; final dates = (data['dates'] as List?) ?? []; final variations = (data['variations'] as List?) ?? []; final extras = (data['extras'] as List?) ?? []; final cutoff = data['sales_cutoff']; final img = summary['image']; final title = '${product['name'] ?? summary['name'] ?? name}'; final desc = '${summary['short_description'] ?? ''}'.trim(); final venue = '${summary['venue'] ?? product['venue'] ?? ''}'.trim(); final date = _prettyDate('${product['date'] ?? summary['date'] ?? ''}'); final remaining = product['global_remaining']; final inStock = summary['in_stock'] != false; dynamic from = summary['price_from']; if (from == null && variations.isNotEmpty) { final prices = variations .map((v) => num.tryParse('${(v as Map)['price']}')) .whereType(); if (prices.isNotEmpty) from = prices.reduce((a, b) => a < b ? a : b); } String? cutoffText; bool closed = false; if (cutoff is Map) { closed = cutoff['closed'] == true; final disp = '${cutoff['display'] ?? ''}'.trim(); if (cutoff['configured'] == true && disp.isNotEmpty) cutoffText = disp; } return Column(children: [ Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: [ Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // Image ClipRRect( borderRadius: BorderRadius.circular(24), child: AspectRatio( aspectRatio: 16 / 10, child: (img is String && img.isNotEmpty) ? Image.network(img, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const DecoratedBox(decoration: BoxDecoration(gradient: _grad))) : const DecoratedBox( decoration: BoxDecoration(gradient: _grad), child: Icon(Icons.directions_bus, color: Colors.white, size: 56)), ), ), const SizedBox(height: 18), Text(title, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w900, height: 1.15, color: _plum, letterSpacing: -0.5)), const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [ if (date.isNotEmpty) _chip(Icons.event_outlined, date), if (venue.isNotEmpty) _chip(Icons.location_on_outlined, venue), if (!inStock) _chip(Icons.block, 'Sold out') else if (remaining is num) _chip(Icons.event_seat_outlined, remaining <= 20 ? 'Only $remaining seats left' : '$remaining seats left'), ]), if (closed || cutoffText != null) Padding( padding: const EdgeInsets.only(top: 10), child: Text( closed ? 'Booking has closed for this trip.' : 'Book by $cutoffText', style: const TextStyle( color: _pink, fontWeight: FontWeight.w700, fontSize: 13), ), ), if (desc.isNotEmpty) ...[ _heading('About this trip'), Text(desc, style: const TextStyle(fontSize: 14.5, height: 1.5, color: _muted)), ], if (dates.length > 1) ...[ _heading('Dates'), Wrap(spacing: 8, runSpacing: 8, children: [ for (final d in dates) _chip(Icons.event_outlined, '${d is Map ? (d['label'] ?? d) : d}'), ]), ], _heading('Choose your pickup'), if (variations.isEmpty) const Text('No pickups are available yet.', style: TextStyle(color: _muted)), for (final v in variations) _pickup(v as Map), if (extras.isNotEmpty) ...[ _heading('Add extras'), for (final x in extras) _card( child: Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${x['name'] ?? ''}', style: const TextStyle( fontWeight: FontWeight.w700, color: _plum)), if (x['available'] != true) const Text('Unavailable', style: TextStyle(fontSize: 12, color: _muted)), ]), ), Text(money(x['price']), style: const TextStyle( fontWeight: FontWeight.w800, color: _purple)), ]), ), ], ]), ), ), ], ), ), // Booking bar Container( padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEBDDFB))), ), child: SafeArea( top: false, child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Row(children: [ if (from != null) Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('From', style: TextStyle(fontSize: 12, color: _muted)), Text(money(from), style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w900, color: _plum)), ]), const Spacer(), const FilledButton( onPressed: null, // Checkout not built yet. child: Padding( padding: EdgeInsets.symmetric(horizontal: 18, vertical: 4), child: Text('Booking coming soon'), ), ), ]), ), ), ), ), ]); }, ), ); } } https://busestoconcerts.com/post-sitemap.xml 2026-09-17T14:16:38+00:00 https://busestoconcerts.com/page-sitemap.xml 2026-10-07T11:10:38+00:00 https://busestoconcerts.com/product-sitemap.xml 2026-10-08T09:46:15+00:00 https://busestoconcerts.com/faq-sitemap.xml 2025-11-21T15:15:56+00:00 https://busestoconcerts.com/porto_builder-sitemap.xml 2026-09-24T14:53:26+00:00 https://busestoconcerts.com/local-sitemap.xml 2025-12-02T12:41:25+00:00