diff --git a/lib/app.dart b/lib/app.dart index 1cf2d8e..bc41447 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:method_conf_app/providers/conference_provider.dart'; +import 'package:method_conf_app/providers/schedule_provider.dart'; +import 'package:method_conf_app/providers/schedule_state_provider.dart'; import 'package:method_conf_app/providers/sponsor_provider.dart'; import 'package:provider/provider.dart'; @@ -24,7 +26,15 @@ class App extends StatelessWidget { ChangeNotifierProvider(create: (context) { var c = Provider.of(context, listen: false); return SponsorProvider(conferenceProvider: c); - }) + }), + ChangeNotifierProvider(create: (context) { + var c = Provider.of(context, listen: false); + return ScheduleProvider(conferenceProvider: c); + }), + ChangeNotifierProvider(create: (context) { + var s = Provider.of(context, listen: false); + return ScheduleStateProvider(scheduleProvider: s); + }), ], child: MaterialApp( debugShowCheckedModeBanner: false, diff --git a/lib/data/get_schedule_grid.dart b/lib/data/get_schedule_grid.dart new file mode 100644 index 0000000..ed696bd --- /dev/null +++ b/lib/data/get_schedule_grid.dart @@ -0,0 +1,15 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:method_conf_app/data/umbraco/models/schedule_grid.dart'; + +import 'package:method_conf_app/env.dart'; + +Future>> getScheduleGrid(String conferenceId) async { + var url = Uri.parse( + '${Env.umbracoBaseUrl}/api/v1/conference/$conferenceId/schedule'); + + final res = await http.get(url); + + return ScheduleGrid.fromJson(json.decode(res.body)).scheduleGrid; +} diff --git a/lib/data/get_schedule_items.dart b/lib/data/get_schedule_items.dart new file mode 100644 index 0000000..dd035fd --- /dev/null +++ b/lib/data/get_schedule_items.dart @@ -0,0 +1,28 @@ +import 'package:method_conf_app/data/umbraco/get_child_nodes_of_type.dart'; +import 'package:method_conf_app/data/umbraco/get_items.dart'; +import 'package:method_conf_app/data/umbraco/models/api_content_model_base.dart'; +import 'package:method_conf_app/data/umbraco/models/session.dart'; +import 'package:method_conf_app/data/umbraco/models/track.dart'; + +const maximumScheduleItems = 100; + +Future> getScheduleItems(conferenceId) async { + final sessions = await getFirstChildNodeOfType( + nodeId: conferenceId, + type: 'sessions', + ); + + if (sessions == null) { + return []; + } + + var response = await getItems( + fetch: 'descendants:${sessions.id}', + expand: 'properties[\$all]', + take: 100, + ); + + return response.items + .where((item) => item is Track || item is Session) + .toList(); +} diff --git a/lib/data/umbraco/get_child_nodes_of_type.dart b/lib/data/umbraco/get_child_nodes_of_type.dart index d84daf1..7109f18 100644 --- a/lib/data/umbraco/get_child_nodes_of_type.dart +++ b/lib/data/umbraco/get_child_nodes_of_type.dart @@ -15,11 +15,11 @@ Future> getChildNodesOfType({ return res.items; } -Future getFirstChildNodeOfType({ +Future getFirstChildNodeOfType({ required String nodeId, required String type, }) async { var items = await getChildNodesOfType(nodeId: nodeId, type: type); - return items.first; + return items.firstOrNull; } diff --git a/lib/data/umbraco/get_items.dart b/lib/data/umbraco/get_items.dart index 478326b..40abb29 100644 --- a/lib/data/umbraco/get_items.dart +++ b/lib/data/umbraco/get_items.dart @@ -1,12 +1,16 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:method_conf_app/data/umbraco/models/paged_api_content_response_model.dart'; +import 'package:method_conf_app/data/umbraco/models/paged_api_content_response_model.dart'; import 'package:method_conf_app/env.dart'; -Future getItems( - {List filter = const [], String? fetch, int? take}) async { +Future getItems({ + List filter = const [], + String? fetch, + String? expand, + int? take, +}) async { var url = Uri.parse('${Env.umbracoBaseUrl}/umbraco/delivery/api/v2/content'); if (filter.isNotEmpty) { @@ -19,12 +23,17 @@ Future getItems( url.replace(queryParameters: {...url.queryParameters, 'fetch': fetch}); } + if (expand != null) { + url = url + .replace(queryParameters: {...url.queryParameters, 'expand': expand}); + } + if (take != null) { url = url.replace( queryParameters: {...url.queryParameters, 'take': take.toString()}); } - var res = await http.get(url); + final res = await http.get(url); return PagedApiContentResponseModel.fromJson(json.decode(res.body)); } diff --git a/lib/data/umbraco/models/api_content_model_base.dart b/lib/data/umbraco/models/api_content_model_base.dart index 9941fae..7c1a711 100644 --- a/lib/data/umbraco/models/api_content_model_base.dart +++ b/lib/data/umbraco/models/api_content_model_base.dart @@ -44,7 +44,11 @@ class ApiContentModelBase { return obj; } - Map toJson() => _$ApiContentModelBaseToJson(this); + Map toJson() { + final json = _$ApiContentModelBaseToJson(this); + json['properties'] = unParsedProperties; + return json; + } } @JsonSerializable() diff --git a/lib/data/umbraco/models/session.dart b/lib/data/umbraco/models/session.dart index 3e22d36..5e59498 100644 --- a/lib/data/umbraco/models/session.dart +++ b/lib/data/umbraco/models/session.dart @@ -10,6 +10,11 @@ part 'session.g.dart'; class Session extends ApiContentModelBase { SessionProperties? properties; + String get gridId { + final parts = route.path.split('/').where((part) => part != ''); + return parts.lastOrNull ?? route.path; + } + Session({ required super.contentType, required super.name, diff --git a/lib/providers/schedule_provider.dart b/lib/providers/schedule_provider.dart new file mode 100644 index 0000000..a29a23d --- /dev/null +++ b/lib/providers/schedule_provider.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:method_conf_app/data/get_schedule_grid.dart'; +import 'package:method_conf_app/data/get_schedule_items.dart'; +import 'package:method_conf_app/data/umbraco/models/api_content_model_base.dart'; +import 'package:method_conf_app/data/umbraco/models/session.dart'; +import 'package:method_conf_app/data/umbraco/models/track.dart'; +import 'package:method_conf_app/providers/conference_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _scheduleItemsStorageKey = 'app-schedule-items'; +const _scheduleGridStorageKey = 'app-schedule-grid'; + +typedef Schedule = (List, List>); + +class ScheduleProvider extends ChangeNotifier { + ConferenceProvider conferenceProvider; + + Schedule? _schedule; + + Schedule? get schedule => _schedule; + + set schedule(Schedule? value) { + _schedule = value; + notifyListeners(); + } + + List> get grid => schedule?.$2 ?? []; + + List get tracks => schedule?.$1.whereType().toList() ?? []; + + List get sessions => + schedule?.$1.whereType().toList() ?? []; + + ScheduleProvider({required this.conferenceProvider}); + + Future init() async { + await conferenceProvider.init(enableBackgroundRefresh: false); + + schedule ??= await load(); + + if (schedule == null) { + await refresh(); + } else { + refresh(); + } + } + + Future load() async { + var prefs = await SharedPreferences.getInstance(); + + final itemsJson = prefs.getStringList(_scheduleItemsStorageKey); + final gridJson = prefs.getStringList(_scheduleGridStorageKey); + + final items = itemsJson + ?.map((item) => ApiContentModelBase.fromJson(json.decode(item))) + .toList(); + + final grid = + gridJson?.map((item) => json.decode(item) as List).toList(); + + if (items == null || grid == null) { + return null; + } + + return (items, grid); + } + + Future store(Schedule? newSchedule) async { + var prefs = await SharedPreferences.getInstance(); + + if (newSchedule == null) { + await Future.wait([ + prefs.remove(_scheduleItemsStorageKey), + prefs.remove(_scheduleGridStorageKey), + ]); + return; + } + + final (items, grid) = newSchedule; + + await Future.wait([ + prefs.setStringList( + _scheduleItemsStorageKey, + items.map((item) => json.encode(item.toJson())).toList(), + ), + prefs.setStringList( + _scheduleGridStorageKey, + grid.map((item) => json.encode(item)).toList(), + ), + ]); + } + + Future fetch() async { + var conference = conferenceProvider.conference; + + if (conference == null) { + return null; + } + + final itemsFuture = getScheduleItems(conference.id); + final gridFuture = getScheduleGrid(conference.id); + + final items = await itemsFuture; + final grid = await gridFuture; + + return (items, grid); + } + + Future refresh() async { + final newSchedule = await fetch(); + + schedule = newSchedule; + + await store(newSchedule); + } +} diff --git a/lib/providers/schedule_state_provider.dart b/lib/providers/schedule_state_provider.dart new file mode 100644 index 0000000..79ce6b7 --- /dev/null +++ b/lib/providers/schedule_state_provider.dart @@ -0,0 +1,72 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:method_conf_app/data/umbraco/models/session.dart'; +import 'package:method_conf_app/data/umbraco/models/track.dart'; +import 'package:method_conf_app/providers/schedule_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _startColumnIndexStorageKey = 'app-schedule-start-column-index'; + +class ScheduleStateProvider extends ChangeNotifier { + ScheduleProvider scheduleProvider; + + int _visibleColumns = 1; + + int get visibleColumns => _visibleColumns; + + set visibleColumns(int value) { + _visibleColumns = value; + notifyListeners(); + } + + int _startColumnIndex = 0; + + int get startColumnIndex => _startColumnIndex; + + set startColumnIndex(int value) { + _startColumnIndex = value; + notifyListeners(); + store(_startColumnIndex); + } + + Track? get currentTrack => + scheduleProvider.tracks.elementAtOrNull(startColumnIndex); + + List get currentSessions => + scheduleProvider.grid + .elementAtOrNull(startColumnIndex) + ?.toSet() + .whereType() + .map((gridId) => scheduleProvider.sessions + .firstWhereOrNull((session) => session.gridId == gridId)) + .whereType() + .toList() ?? + []; + + ScheduleStateProvider({required this.scheduleProvider}); + + Future init() async { + final storedStartColumnIndex = await load(); + + if (storedStartColumnIndex != null) { + startColumnIndex = storedStartColumnIndex; + } + } + + Future load() async { + var prefs = await SharedPreferences.getInstance(); + + return prefs.getInt(_startColumnIndexStorageKey); + } + + Future store(int? newStartColumnIndex) async { + var prefs = await SharedPreferences.getInstance(); + + if (newStartColumnIndex == null) { + await prefs.remove(_startColumnIndexStorageKey); + return; + } + + await prefs.setInt(_startColumnIndexStorageKey, newStartColumnIndex); + } +} diff --git a/lib/screens/schedule_screen.dart b/lib/screens/schedule_screen.dart index 10d7d9e..550aeb0 100644 --- a/lib/screens/schedule_screen.dart +++ b/lib/screens/schedule_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:method_conf_app/providers/conference_provider.dart'; +import 'package:method_conf_app/providers/schedule_provider.dart'; +import 'package:method_conf_app/providers/schedule_state_provider.dart'; import 'package:method_conf_app/widgets/app_banner.dart'; import 'package:provider/provider.dart'; @@ -19,41 +22,53 @@ class ScheduleScreen extends StatefulWidget { } class _ScheduleScreenState extends State { - Future? _sessionFuture; + Future? _scheduleFuture; @override void initState() { super.initState(); var sessionProvider = Provider.of(context, listen: false); - - _sessionFuture = sessionProvider.fetchInitialSession(); + final scheduleProvider = + Provider.of(context, listen: false); + final scheduleStateProvider = + Provider.of(context, listen: false); + + _scheduleFuture = Future.wait([ + scheduleProvider.init(), + scheduleStateProvider.init(), + ]); } @override Widget build(BuildContext context) { - var sessionProvider = Provider.of(context); + final scheduleProvider = Provider.of(context); + final conferenceProvider = Provider.of(context); + final scheduleStateProvider = Provider.of(context); - var eventDate = DateTime.parse(Env.eventDate); - var dateFormString = 'EEEE, MMMM d\'${daySuffix(eventDate.day)}\', y'; + final eventDate = conferenceProvider.conference?.properties?.date; + final currentTrack = scheduleStateProvider.currentTrack; return AppScreen( title: 'Schedule', body: PageLoader( - future: _sessionFuture, + future: _scheduleFuture, child: RefreshIndicator( onRefresh: () async { - await sessionProvider.fetchSessions(); + await scheduleProvider.refresh(); }, child: ListView( padding: const EdgeInsets.all(20), physics: const AlwaysScrollableScrollPhysics(), children: [ - ..._buildBanner(eventDate), - Text( - DateFormat(dateFormString).format(eventDate), - style: const TextStyle(fontSize: 20), - ), + if (eventDate != null) ..._buildBanner(eventDate), + if (eventDate != null) + Text( + DateFormat('EEEE, MMMM d\'${daySuffix(eventDate.day)}\', y') + .format(eventDate), + style: const TextStyle(fontSize: 20), + ), + if (currentTrack != null) Text(currentTrack.name ?? ''), const SizedBox(height: 15), _buildSimpleCard(time: '7:45AM', title: 'Check-in/Breakfast'), const SizedBox(height: 15),