From fc19ed21b0ffc5d4ae61bcfe6ccbf4b471fb2e50 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Tue, 23 Jun 2026 17:35:00 -0700 Subject: [PATCH 01/10] Implement stale-while-revalidate behavior --- lib/datatypes.dart | 11 +- lib/metrics.dart | 27 +- lib/pages/alliance.dart | 45 +- lib/pages/archived_scouters.dart | 82 ++- lib/pages/initial_loader.dart | 2 + lib/pages/match_predictor.dart | 322 ++++++---- lib/pages/match_schedule.dart | 129 ++-- lib/pages/picklist/mutable_picklist.dart | 18 +- lib/pages/picklist/picklist.dart | 151 +++-- lib/pages/picklist/picklists.dart | 583 +++++++++++------- lib/pages/picklist/shared_picklist.dart | 155 +++-- lib/pages/picklist/view_picklist_weights.dart | 21 +- .../scout_schedule/edit_scout_schedule.dart | 160 +++-- lib/pages/scouters.dart | 85 ++- lib/pages/settings.dart | 4 +- .../tabs/team_lookup_breakdowns.dart | 102 ++- .../tabs/team_lookup_categories.dart | 181 +++--- .../team_lookup/tabs/team_lookup_notes.dart | 143 +++-- .../team_lookup_breakdown_details.dart | 44 +- .../team_lookup/team_lookup_details.dart | 36 +- lib/reusable/flag_models.dart | 30 +- .../lovat_api/get_alliance_analysis.dart | 13 + lib/reusable/lovat_api/get_flags.dart | 17 + .../lovat_api/get_match_prediction.dart | 23 + lib/reusable/lovat_api/get_matches.dart | 15 + .../lovat_api/get_scouter_overviews.dart | 16 + lib/reusable/lovat_api/get_scouts.dart | 22 +- lib/reusable/lovat_api/get_user_profile.dart | 8 + lib/reusable/lovat_api/lovat_api.dart | 53 +- .../picklists/get_picklist_analysis.dart | 24 + .../mutable/get_mutable_picklist_by_id.dart | 6 + .../mutable/get_mutable_picklists.dart | 9 + .../shared/get_shared_picklist_by_id.dart | 6 + .../shared/get_shared_picklists.dart | 9 + lib/reusable/lovat_api/response_cache.dart | 109 ++++ .../get_scouter_schedule.dart | 8 + .../team_lookup/get_breakdown_details.dart | 14 + .../team_lookup/get_breakdown_metrics.dart | 8 + .../team_lookup/get_category_metrics.dart | 8 + .../team_lookup/get_metric_details.dart | 9 + .../lovat_api/team_lookup/get_notes.dart | 14 + lib/reusable/navigation_drawer.dart | 6 +- lib/reusable/stale_refresh_indicator.dart | 70 +++ 43 files changed, 1932 insertions(+), 866 deletions(-) create mode 100644 lib/reusable/lovat_api/response_cache.dart create mode 100644 lib/reusable/stale_refresh_indicator.dart diff --git a/lib/datatypes.dart b/lib/datatypes.dart index f8dc3bec..f7306edc 100644 --- a/lib/datatypes.dart +++ b/lib/datatypes.dart @@ -11,6 +11,8 @@ class Tournament { String key; String localized; + static Tournament? _currentCache; + @override String toString() => localized; @@ -25,9 +27,14 @@ class Tournament { final prefs = await SharedPreferences.getInstance(); await prefs.setString('tournament', key); await prefs.setString('tournament_localized', localized); + _currentCache = this; } + static Tournament? get currentSync => _currentCache; + static Future getCurrent() async { + if (_currentCache != null) return _currentCache; + final prefs = await SharedPreferences.getInstance(); final key = prefs.getString('tournament'); final name = prefs.getString('tournament_localized'); @@ -36,13 +43,15 @@ class Tournament { return null; } - return Tournament(key, name); + _currentCache = Tournament(key, name); + return _currentCache; } static Future clearCurrent() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove('tournament'); await prefs.remove('tournament_localized'); + _currentCache = null; } Future> getMatches() async { diff --git a/lib/metrics.dart b/lib/metrics.dart index 722f826e..7d9bf167 100644 --- a/lib/metrics.dart +++ b/lib/metrics.dart @@ -96,24 +96,24 @@ String numToStringRounded(num? num) { final List metricCategories = [ MetricCategoryData("Score", [ CategoryMetric( - localizedName: "Total", + localizedName: "Total score in a match", abbreviatedLocalizedName: "Total", path: "totalPoints", ), CategoryMetric( - localizedName: "Auto", + localizedName: "Total score during autonomous", abbreviatedLocalizedName: "Auto", path: "autoPoints", ), CategoryMetric( - localizedName: "Teleop", + localizedName: "Total score during teleop", abbreviatedLocalizedName: "Teleop", path: "teleopPoints", ), ]), MetricCategoryData("Hub", [ CategoryMetric( - localizedName: "Scoring Rate (Fuel / Second)", + localizedName: "Fuel scored per second", abbreviatedLocalizedName: "Scoring Rate", units: " bps", path: "fuelPerSecond", @@ -133,26 +133,26 @@ final List metricCategories = [ ]), MetricCategoryData("Feeding", [ CategoryMetric( - localizedName: "Time Spent Feeding", + localizedName: "Time spent feeding in a match", abbreviatedLocalizedName: "Time Feeding", units: "s", path: "timeFeeding", ), CategoryMetric( - localizedName: "Feeding Rate (Fuel / Second)", + localizedName: "Fuel fed per second", abbreviatedLocalizedName: "Feeding Rate", units: " bps", path: "feedingRate", ), CategoryMetric( - localizedName: "Feeds per match", + localizedName: "Feeing volleys/dumps per match", abbreviatedLocalizedName: "Feeds/Match", path: "feedsPerMatch", ) ]), MetricCategoryData("Driving & Defense", [ CategoryMetric( - localizedName: "Driver Ability", + localizedName: "Manually scored driving rating", abbreviatedLocalizedName: "Driver Ability", max: 5, units: "1-5", @@ -160,13 +160,13 @@ final List metricCategories = [ path: "driverAbility", ), CategoryMetric( - localizedName: "Contact Defense Time", + localizedName: "Time spent doing contact defense in a match", abbreviatedLocalizedName: "Contact Defense Time", units: "s", path: "contactDefenseTime", ), CategoryMetric( - localizedName: "Defense effectiveness", + localizedName: "Manually scored defense rating", abbreviatedLocalizedName: "Defense effectiveness", units: "1-5", valueToString: (val) => "$val/5", @@ -174,13 +174,13 @@ final List metricCategories = [ path: "defenseEffectiveness", ), CategoryMetric( - localizedName: "Camping Defense Time", + localizedName: "Time spent doing camping defense in a match", abbreviatedLocalizedName: "Camping Defense Time", units: "s", path: "campingDefenseTime", ), CategoryMetric( - localizedName: "Total Defense Time (Camping + Contact)", + localizedName: "Total defense time (camping + contact)", abbreviatedLocalizedName: "Total Defense Time", units: "s", path: "totalDefenseTime", @@ -219,7 +219,8 @@ final List metricCategories = [ path: "totalFuelOutputted", ), CategoryMetric( - localizedName: "Outpost Intakes", + localizedName: + "Number of times outpost was used to intake during a match", abbreviatedLocalizedName: "Outpost Intakes", path: "outpostIntakes", ) diff --git a/lib/pages/alliance.dart b/lib/pages/alliance.dart index 49d7543b..00062204 100644 --- a/lib/pages/alliance.dart +++ b/lib/pages/alliance.dart @@ -6,6 +6,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/get_alliance_analysis. import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/robot_roles.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/team_auto_paths.dart'; import 'package:scouting_dashboard_app/reusable/value_tile.dart'; @@ -20,20 +21,39 @@ class _AlliancePageState extends State { AllianceAnalysis? data; String? error; late List teams; + bool isRefreshing = false; Future fetchData() async { + // Show stale data from cache immediately + final cached = lovatAPI.getCachedAllianceAnalysis(teams); + if (cached != null && data == null && error == null) { + setState(() { + data = cached; + }); + } + setState(() { - data = null; - error = null; + isRefreshing = true; }); try { final result = await lovatAPI.getAllianceAnalysis(teams); - setState(() => data = result); + setState(() { + data = result; + error = null; + }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (data == null) { + setState(() => error = e.message); + } } catch (_) { - setState(() => error = "Failed to load alliance data"); + if (data == null) { + setState(() => error = "Failed to load alliance data"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -42,7 +62,7 @@ class _AlliancePageState extends State { super.didChangeDependencies(); teams = (ModalRoute.of(context)!.settings.arguments as Map)['teams']; - if (data == null && error == null) fetchData(); + if (data == null && error == null && !isRefreshing) fetchData(); } @override @@ -50,20 +70,17 @@ class _AlliancePageState extends State { return Scaffold( appBar: AppBar( title: const Text("Alliance"), - actions: [ - if (error != null || data != null) - IconButton( - icon: const Icon(Icons.refresh), - onPressed: fetchData, - ), - ], + bottom: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), ), body: _buildBody(), ); } Widget _buildBody() { - if (error != null) { + if (error != null && data == null) { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } diff --git a/lib/pages/archived_scouters.dart b/lib/pages/archived_scouters.dart index 746eba71..358a9e0d 100644 --- a/lib/pages/archived_scouters.dart +++ b/lib/pages/archived_scouters.dart @@ -7,6 +7,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; class ArchivedScoutersPage extends StatefulWidget { @@ -25,27 +26,52 @@ class _ArchivedScoutersPageState extends State { List? scouterOverviews; String? error; Tournament? tournament; + bool isRefreshing = false; String filterText = ''; Future fetchData() async { - try { + final t = Tournament.currentSync ?? await Tournament.getCurrent(); + + setState(() { + tournament = t; + }); + + // Show stale data from cache immediately + final cached = lovatAPI.getCachedScouterOverviews( + tournamentKey: t?.key, + archivedScouters: true, + ); + if (cached != null && scouterOverviews == null && error == null) { setState(() { - scouterOverviews = null; - error = null; + scouterOverviews = cached; }); - final t = await Tournament.getCurrent(); + } + + setState(() { + isRefreshing = true; + }); + + try { final data = await lovatAPI.getScouterOverviews(archivedScouters: true); setState(() { scouterOverviews = data; - tournament = t; + error = null; }); } on LovatAPIException catch (e) { - setState(() { - error = e.message; - }); + if (scouterOverviews == null) { + setState(() { + error = e.message; + }); + } } catch (_) { + if (scouterOverviews == null) { + setState(() { + error = "Failed to load scouters"; + }); + } + } finally { setState(() { - error = "Failed to load scouters"; + isRefreshing = false; }); } } @@ -143,7 +169,7 @@ class _ArchivedScoutersPageState extends State { } } - if (error != null) { + if (error != null && scouterOverviews == null) { body = FriendlyErrorView(errorMessage: error, onRetry: fetchData); } @@ -151,21 +177,29 @@ class _ArchivedScoutersPageState extends State { appBar: AppBar( title: const Text("Archived Scouters"), bottom: PreferredSize( - preferredSize: const Size.fromHeight(80), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - onChanged: (text) { - setState(() { - filterText = text; - }); - }, - decoration: const InputDecoration( - filled: true, - labelText: "Search", + preferredSize: const Size.fromHeight(84), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + onChanged: (text) { + setState(() { + filterText = text; + }); + }, + decoration: const InputDecoration( + filled: true, + labelText: "Search", + ), + autofocus: true, + ), ), - autofocus: true, - ), + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: scouterOverviews != null, + ), + ], ))), body: body, ); diff --git a/lib/pages/initial_loader.dart b/lib/pages/initial_loader.dart index 08c56934..2f06d344 100644 --- a/lib/pages/initial_loader.dart +++ b/lib/pages/initial_loader.dart @@ -21,6 +21,8 @@ class _InitialLoaderPageState extends State { final navigator = Navigator.of(context); final prefs = await SharedPreferences.getInstance(); + await lovatAPI.cache.load(); + // Retrieve the base API URL if (prefs.containsKey('api_base_url')) { lovatAPI.baseUrl = prefs.getString('api_base_url')!; diff --git a/lib/pages/match_predictor.dart b/lib/pages/match_predictor.dart index 7e632221..12e43737 100644 --- a/lib/pages/match_predictor.dart +++ b/lib/pages/match_predictor.dart @@ -10,6 +10,7 @@ import 'package:scouting_dashboard_app/reusable/models/robot_roles.dart'; import 'package:scouting_dashboard_app/reusable/navigation_drawer.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/value_tile.dart'; class MatchPredictorPage extends StatefulWidget { @@ -20,158 +21,217 @@ class MatchPredictorPage extends StatefulWidget { } class _MatchPredictorPageState extends State { - Future _fetchPrediction(Map args) async { + MatchPrediction? prediction; + String? error; + bool isRefreshing = false; + bool _notEnoughData = false; + + late List _teams; + + Future fetchData() async { + // Show stale data from cache immediately + final cached = lovatAPI.getCachedMatchPrediction( + _teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5], + ); + if (cached != null && prediction == null && error == null) { + setState(() { + prediction = cached; + }); + } + + setState(() { + isRefreshing = true; + }); + try { - return await lovatAPI.getMatchPrediction( - int.parse(args['red1']), - int.parse(args['red2']), - int.parse(args['red3']), - int.parse(args['blue1']), - int.parse(args['blue2']), - int.parse(args['blue3']), + final result = await lovatAPI.getMatchPrediction( + _teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5], ); + setState(() { + prediction = result; + error = null; + _notEnoughData = false; + }); } on LovatAPIException catch (e) { if (e.message == "Not enough data") { - throw _NotEnoughDataException(); + if (prediction == null) { + setState(() { + _notEnoughData = true; + }); + } + } else if (prediction == null) { + setState(() { + error = e.message; + }); + } + } catch (e) { + if (prediction == null) { + setState(() { + error = "Error: $e"; + }); } - rethrow; + } finally { + setState(() { + isRefreshing = false; + }); } } @override - Widget build(BuildContext context) { + void didChangeDependencies() { + super.didChangeDependencies(); final args = ModalRoute.of(context)!.settings.arguments! as Map; + _teams = [ + int.parse(args['red1']), + int.parse(args['red2']), + int.parse(args['red3']), + int.parse(args['blue1']), + int.parse(args['blue2']), + int.parse(args['blue3']), + ]; + if (prediction == null && error == null && !isRefreshing && !_notEnoughData) { + fetchData(); + } + } - return DefaultTabController( - length: 2, - child: FutureBuilder( - future: _fetchPrediction(args), - builder: (context, snapshot) { - if (snapshot.hasError) { - if (snapshot.error is _NotEnoughDataException) { - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - ), - body: notEnoughDataMessage(), - drawer: (ModalRoute.of(context)!.settings.arguments == null) - ? const GlobalNavigationDrawer() - : null, - ); - } + @override + Widget build(BuildContext context) { + final hasDrawer = ModalRoute.of(context)!.settings.arguments == null + ? const GlobalNavigationDrawer() + : null; - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - ), - body: PageBody(child: Text("Error: ${snapshot.error}")), - drawer: (ModalRoute.of(context)!.settings.arguments == null) - ? const GlobalNavigationDrawer() - : null, - ); - } + if (_notEnoughData && prediction == null) { + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: prediction != null, + ), + ), + body: notEnoughDataMessage(), + drawer: hasDrawer, + ); + } - if (snapshot.connectionState != ConnectionState.done) { - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - ), - body: const PageBody(child: LinearProgressIndicator()), - drawer: (ModalRoute.of(context)!.settings.arguments == null) - ? const GlobalNavigationDrawer() - : null, - ); - } + if (prediction == null) { + if (error != null) { + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: false, + ), + ), + body: PageBody(child: Text("Error: $error")), + drawer: hasDrawer, + ); + } - final prediction = snapshot.data!; + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + ), + body: const PageBody(child: LinearProgressIndicator()), + drawer: hasDrawer, + ); + } - return LayoutBuilder(builder: (context, constraints) { - if (constraints.maxHeight > constraints.maxWidth) { - // Portrait - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(85), - child: Column( + return DefaultTabController( + length: 2, + child: LayoutBuilder(builder: (context, constraints) { + if (constraints.maxHeight > constraints.maxWidth) { + // Portrait + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(89), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: WinningPrediction( + redWinning: prediction!.redWinning, + blueWinning: prediction!.blueWinning, + ), + ), + const TabBar(tabs: [ + Column( children: [ - Padding( - padding: const EdgeInsets.all(16), - child: WinningPrediction( - redWinning: prediction.redWinning, - blueWinning: prediction.blueWinning, - ), - ), - const TabBar(tabs: [ - Column( - children: [ - Text("Red"), - SizedBox(height: 7), - ], - ), - Column( - children: [ - Text("Blue"), - SizedBox(height: 7), - ], - ), - ]), + Text("Red"), + SizedBox(height: 7), ], ), - ), - ), - body: TabBarView(children: [ - ScrollablePageBody(children: [ - allianceTab(0, prediction.redAlliance), - ]), - ScrollablePageBody(children: [ - allianceTab(1, prediction.blueAlliance), - ]), - ]), - drawer: (ModalRoute.of(context)!.settings.arguments == null) - ? const GlobalNavigationDrawer() - : null, - ); - } else { - // Landscape - return Scaffold( - appBar: AppBar(title: const Text("Match Predictor")), - body: SafeArea( - bottom: false, - child: ListView(children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: WinningPrediction( - redWinning: prediction.redWinning, - blueWinning: prediction.blueWinning, - ), + Column( + children: [ + Text("Blue"), + SizedBox(height: 7), + ], ), - Row(children: [ - Flexible( - flex: 1, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: allianceTab(0, prediction.redAlliance), - ), - ), - Flexible( - flex: 1, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: allianceTab(1, prediction.blueAlliance), - ), - ), - ]), ]), + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: prediction != null, + ), + ], + ), + ), + ), + body: TabBarView(children: [ + ScrollablePageBody(children: [ + allianceTab(0, prediction!.redAlliance), + ]), + ScrollablePageBody(children: [ + allianceTab(1, prediction!.blueAlliance), + ]), + ]), + drawer: hasDrawer, + ); + } else { + // Landscape + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: prediction != null, + ), + ), + body: SafeArea( + bottom: false, + child: ListView(children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: WinningPrediction( + redWinning: prediction!.redWinning, + blueWinning: prediction!.blueWinning, + ), + ), + Row(children: [ + Flexible( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: allianceTab(0, prediction!.redAlliance), + ), ), - drawer: (ModalRoute.of(context)!.settings.arguments == null) - ? const GlobalNavigationDrawer() - : null, - ); - } - }); - }), + Flexible( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: allianceTab(1, prediction!.blueAlliance), + ), + ), + ]), + ]), + ), + drawer: hasDrawer, + ); + } + }), ); } @@ -351,8 +411,6 @@ class _MatchPredictorPageState extends State { } } -class _NotEnoughDataException implements Exception {} - class WinningPrediction extends StatelessWidget { const WinningPrediction({ super.key, diff --git a/lib/pages/match_schedule.dart b/lib/pages/match_schedule.dart index 5f1c304c..fd468c81 100644 --- a/lib/pages/match_schedule.dart +++ b/lib/pages/match_schedule.dart @@ -14,6 +14,7 @@ import 'package:scouting_dashboard_app/reusable/models/team.dart'; import 'package:scouting_dashboard_app/reusable/models/user_profile.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; import '../reusable/navigation_drawer.dart'; @@ -62,60 +63,55 @@ class _MatchSchedulePageState extends State { } Future fetchData({bool indicator = true}) async { - try { - if (indicator && currentTournament != null) { - setState(() { - showProgressIndicator = true; - }); - } + final tournament = Tournament.currentSync ?? await Tournament.getCurrent(); - final tournament = await Tournament.getCurrent(); + setState(() { + currentTournament = tournament; + }); + if (tournament == null) { setState(() { - currentTournament = tournament; + noScheduleTournament = "No tournament selected"; + isDataFetched = true; }); + return; + } - if (tournament == null) { - setState(() { - noScheduleTournament = "No tournament selected"; - isDataFetched = true; - }); - return; - } + final teamNumbers = _teamsFilter.isEmpty + ? null + : _teamsFilter.map((e) => e.number).toList(); - final matches = await lovatAPI.getMatches( - tournament.key, - teamNumbers: _teamsFilter.isEmpty - ? null - : _teamsFilter.map((e) => e.number).toList(), - ); + if (indicator) { + setState(() { + showProgressIndicator = true; + }); + } - GameMatchIdentity? nextMatch = this.nextMatch; - if (_teamsFilter.isEmpty && - completionFilter == CompletionFilter.any && - matches.isNotEmpty) { - final MatchScheduleMatch? lastScouted = matches.cast().lastWhere( - (match) => match.isScouted, - orElse: () => null, - ); - - if (lastScouted == null) { - nextMatch = matches.first.identity; - } else { - final index = matches.indexOf(lastScouted); + // Show stale data from cache immediately + final cachedMatches = lovatAPI.getCachedMatches( + tournament.key, + teamNumbers: teamNumbers, + ); + if (cachedMatches != null && matches == null && initialError == null) { + setState(() { + matches = cachedMatches; + nextMatch = _computeNextMatch(cachedMatches); + isDataFetched = true; + }); + } - if (index == matches.length - 1) { - nextMatch = null; - } else { - nextMatch = matches[index + 1].identity; - } - } - } + // Fetch fresh data + try { + final freshMatches = await lovatAPI.getMatches( + tournament.key, + teamNumbers: teamNumbers, + ); setState(() { - this.matches = matches; - this.nextMatch = nextMatch; + matches = freshMatches; + nextMatch = _computeNextMatch(freshMatches); isDataFetched = true; + initialError = null; }); } on LovatAPIException catch (e) { if (e.message == "Tournament not found") { @@ -124,15 +120,17 @@ class _MatchSchedulePageState extends State { currentTournament?.localized ?? "No tournament"; isDataFetched = true; }); - } else { + } else if (matches == null) { setState(() { initialError = e.message; }); } } catch (e) { - setState(() { - initialError = "An unknown error occurred"; - }); + if (matches == null) { + setState(() { + initialError = "An unknown error occurred"; + }); + } } finally { setState(() { showProgressIndicator = false; @@ -140,6 +138,30 @@ class _MatchSchedulePageState extends State { } } + GameMatchIdentity? _computeNextMatch(List matches) { + if (_teamsFilter.isEmpty && + completionFilter == CompletionFilter.any && + matches.isNotEmpty) { + final MatchScheduleMatch? lastScouted = matches.cast().lastWhere( + (match) => match.isScouted, + orElse: () => null, + ); + + if (lastScouted == null) { + return matches.first.identity; + } else { + final index = matches.indexOf(lastScouted); + + if (index == matches.length - 1) { + return null; + } else { + return matches[index + 1].identity; + } + } + } + return nextMatch; + } + Future fetchTeamsInTournament() async { final scaffoldMessengerState = ScaffoldMessenger.of(context); final themeData = Theme.of(context); @@ -270,16 +292,9 @@ class _MatchSchedulePageState extends State { ], ), ), - SizedBox( - height: 4, - child: showProgressIndicator - ? const LinearProgressIndicator() - : Divider( - height: 1, - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), + StaleRefreshIndicator( + isRefreshing: showProgressIndicator, + hasStaleData: matches != null, ), ], ), diff --git a/lib/pages/picklist/mutable_picklist.dart b/lib/pages/picklist/mutable_picklist.dart index 6a0b772f..416d0f66 100644 --- a/lib/pages/picklist/mutable_picklist.dart +++ b/lib/pages/picklist/mutable_picklist.dart @@ -221,10 +221,20 @@ class MutablePicklistFlagRow extends StatefulWidget { class _MutablePicklistFlagRowState extends State { Future loadData() async { - final flags = await lovatAPI.getFlags( - widget.flagConfigurations.map((e) => e.type.path).toList(), - widget.team, - ); + final flagPaths = + widget.flagConfigurations.map((e) => e.type.path).toList(); + + final cachedFlags = lovatAPI.getCachedFlags(flagPaths, widget.team); + if (cachedFlags != null) { + widget.onLoad(cachedFlags.asMap().map( + (key, value) => MapEntry( + widget.flagConfigurations[key].type.path, + value, + ), + )); + } + + final flags = await lovatAPI.getFlags(flagPaths, widget.team); widget.onLoad(flags.asMap().map( (key, value) => MapEntry( diff --git a/lib/pages/picklist/picklist.dart b/lib/pages/picklist/picklist.dart index b1d04123..fcafa93c 100644 --- a/lib/pages/picklist/picklist.dart +++ b/lib/pages/picklist/picklist.dart @@ -12,6 +12,7 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/get_picklist_analysis.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:share_plus/share_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:http/http.dart' as http; @@ -154,27 +155,45 @@ class _PicklistViewState extends State { List? data; List? flags; String? error; + bool isRefreshing = false; Future fetchData() async { + final fetchedFlags = await getPicklistFlags(); + final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); + + final cached = lovatAPI.getCachedPicklistAnalysis( + flagPaths, widget.picklist.weights); + if (cached != null && data == null && error == null) { + setState(() { + flags = fetchedFlags; + data = cached; + }); + } + setState(() { - data = null; - flags = null; - error = null; + isRefreshing = true; }); try { - final fetchedFlags = await getPicklistFlags(); - final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); final result = await lovatAPI.getPicklistAnalysis( flagPaths, widget.picklist.weights); setState(() { flags = fetchedFlags; data = result; + error = null; }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (data == null) { + setState(() => error = e.message); + } } catch (_) { - setState(() => error = "Failed to load picklist"); + if (data == null) { + setState(() => error = "Failed to load picklist"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -186,7 +205,7 @@ class _PicklistViewState extends State { @override Widget build(BuildContext context) { - if (error != null) { + if (error != null && data == null) { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } @@ -198,57 +217,73 @@ class _PicklistViewState extends State { final result = data!; - return ListView( - children: result - .map((teamData) => ListTile( - title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlagRow( - flags!, - Map.fromEntries( - teamData.flags.map((e) => MapEntry(e.type, e.result)), - ), - teamData.teamNumber, - onEdit: fetchData, - ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - "/picklist_team_breakdown", - arguments: { - 'team': teamData.teamNumber, - 'breakdown': teamData.zScoresWeighted, - 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklist.meta.title, - }); - }, - icon: Icon( - Icons.balance, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: - "View ${teamData.teamNumber}'s z-scores", - ), - IconButton( - onPressed: () { - Navigator.of(context) - .pushNamed("/team_lookup", arguments: { - 'team': teamData.teamNumber, - }); - }, - icon: Icon( - Icons.arrow_right, - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), + Expanded( + child: ListView( + children: result + .map((teamData) => ListTile( + title: Text(teamData.teamNumber.toString()), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlagRow( + flags!, + Map.fromEntries( + teamData.flags + .map((e) => MapEntry(e.type, e.result)), + ), + teamData.teamNumber, + onEdit: fetchData, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + "/picklist_team_breakdown", + arguments: { + 'team': teamData.teamNumber, + 'breakdown': teamData.zScoresWeighted, + 'unweighted': teamData.zScoresUnweighted, + 'picklistTitle': widget.picklist.meta.title, + }); + }, + icon: Icon( + Icons.balance, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + tooltip: + "View ${teamData.teamNumber}'s z-scores", + ), + IconButton( + onPressed: () { + Navigator.of(context) + .pushNamed("/team_lookup", arguments: { + 'team': teamData.teamNumber, + }); + }, + icon: Icon( + Icons.arrow_right, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + tooltip: + "Open team lookup for ${teamData.teamNumber}", + ), + ], ), - tooltip: "Open team lookup for ${teamData.teamNumber}", - ), - ], - ), - )) - .toList(), + )) + .toList(), + ), + ), + ], ); } } diff --git a/lib/pages/picklist/picklists.dart b/lib/pages/picklist/picklists.dart index d7ab8a09..e4b200f3 100644 --- a/lib/pages/picklist/picklists.dart +++ b/lib/pages/picklist/picklists.dart @@ -9,6 +9,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/shared/get_s import 'package:scouting_dashboard_app/reusable/navigation_drawer.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; class PicklistsPage extends StatefulWidget { const PicklistsPage({super.key}); @@ -92,6 +93,9 @@ class MyPicklists extends StatefulWidget { } class _MyPicklistsState extends State { + List? picklists; + String? error; + @override void initState() { super.initState(); @@ -99,36 +103,226 @@ class _MyPicklistsState extends State { widget.onCallFrontAvailable(() { setState(() {}); }); + + _loadPicklists(); + } + + Future _loadPicklists() async { + try { + final data = await getPicklists(); + setState(() { + picklists = data; + }); + } catch (e) { + setState(() { + error = e.toString(); + }); + } } @override Widget build(BuildContext context) { - return FutureBuilder( - future: getPicklists(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return PageBody( - child: Text( - "Encountered an error while fetching picklists:${snapshot.error}"), - ); - } - - if (snapshot.connectionState != ConnectionState.done) { - return const PageBody( - padding: EdgeInsets.zero, - child: Column( + if (error != null && picklists == null) { + return PageBody( + child: Text("Encountered an error while fetching picklists:$error"), + ); + } + + if (picklists == null) { + return const PageBody( + padding: EdgeInsets.zero, + child: Column( + children: [ + LinearProgressIndicator(), + ], + ), + ); + } + + return ScrollablePageBody( + padding: EdgeInsets.zero, + children: picklists! + .map((picklist) => Column( children: [ - LinearProgressIndicator(), + Dismissible( + onUpdate: (details) { + if ((details.reached && !details.previousReached) || + (!details.reached && details.previousReached)) { + HapticFeedback.lightImpact(); + } + }, + key: GlobalKey(), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red[900], + child: const Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon(Icons.delete), + SizedBox(width: 30), + ], + ), + ), + ), + child: ListTile( + title: Text(picklist.title), + trailing: Icon( + Icons.arrow_right, + color: Theme.of(context).colorScheme.onSurface, + ), + onTap: () { + Navigator.of(context).pushNamed('/picklist', + arguments: { + 'picklist': picklist, + 'onChanged': () async { + await setPicklists(picklists!); + + setState(() {}); + } + }); + }, + ), + onDismissed: (direction) async { + final scaffoldMessengerState = + ScaffoldMessenger.of(context); + final themeData = Theme.of(context); + + picklists!.remove(picklist); + + await setPicklists(picklists!); + + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text('Deleted "${picklist.title}"'), + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: "Undo", + onPressed: () async { + try { + picklists!.add(picklist); + await setPicklists(picklists!); + setState(() {}); + } catch (error) { + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text( + error.toString(), + style: TextStyle( + color: themeData + .colorScheme.onErrorContainer), + ), + backgroundColor: + themeData.colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + ), + ); + } + }), + ), + ); + }, + ), + Divider( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + height: 0, + ), ], - ), - ); - } + )) + .toList(), + ); + } +} + +class SharedPicklists extends StatefulWidget { + const SharedPicklists({ + super.key, + }); + + @override + State createState() => _SharedPicklistsState(); +} + +class _SharedPicklistsState extends State { + List? picklists; + String? error; + bool isRefreshing = false; + + @override + void initState() { + super.initState(); + fetchData(); + } - List picklists = snapshot.data!; + Future fetchData() async { + final cached = lovatAPI.getCachedSharedPicklists(); + if (cached != null && picklists == null && error == null) { + setState(() { + picklists = cached; + }); + } - return ScrollablePageBody( + setState(() { + isRefreshing = true; + }); + + try { + final data = await lovatAPI.getSharedPicklists(); + setState(() { + picklists = data; + error = null; + }); + } on LovatAPIException catch (e) { + if (e.message == "Not on team" && picklists == null) { + setState(() { + error = "Not on team"; + }); + } else if (picklists == null) { + setState(() { + error = e.toString(); + }); + } + } catch (e) { + if (picklists == null) { + setState(() { + error = e.toString(); + }); + } + } finally { + setState(() { + isRefreshing = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (error != null && picklists == null) { + if (error == "Not on team") { + return const NotOnTeamMessage(); + } + return FriendlyErrorView( + errorMessage: error!, + onRetry: fetchData, + ); + } + + if (picklists == null) { + return const Column(children: [LinearProgressIndicator()]); + } + + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: picklists != null, + ), + Expanded( + child: ScrollablePageBody( padding: EdgeInsets.zero, - children: picklists + children: picklists! .map((picklist) => Column( children: [ Dismissible( @@ -154,20 +348,20 @@ class _MyPicklistsState extends State { ), child: ListTile( title: Text(picklist.title), + subtitle: picklist.author == null + ? null + : Text(picklist.author!), trailing: Icon( Icons.arrow_right, color: Theme.of(context).colorScheme.onSurface, ), onTap: () { - Navigator.of(context).pushNamed('/picklist', - arguments: { - 'picklist': picklist, - 'onChanged': () async { - await setPicklists(picklists); - - setState(() {}); - } - }); + Navigator.of(context).pushNamed( + "/shared_picklist", + arguments: { + 'picklist': picklist, + }, + ); }, ), onDismissed: (direction) async { @@ -175,39 +369,48 @@ class _MyPicklistsState extends State { ScaffoldMessenger.of(context); final themeData = Theme.of(context); - picklists.remove(picklist); + try { + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text("Deleting..."), + behavior: SnackBarBehavior.floating, + ), + ); - await setPicklists(picklists); + await lovatAPI.deleteSharedPicklist(picklist.id); - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text('Deleted "${picklist.title}"'), - behavior: SnackBarBehavior.floating, - action: SnackBarAction( - label: "Undo", - onPressed: () async { - try { - picklists.add(picklist); - await setPicklists(picklists); - setState(() {}); - } catch (error) { - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text( - error.toString(), - style: TextStyle( - color: themeData.colorScheme - .onErrorContainer), - ), - backgroundColor: themeData - .colorScheme.errorContainer, - behavior: SnackBarBehavior.floating, - ), - ); - } - }), - ), - ); + setState(() { + picklists!.removeWhere( + (p) => p.id == picklist.id); + }); + + scaffoldMessengerState.hideCurrentSnackBar(); + + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text( + 'Successfully deleted picklist "${picklist.title}"', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (error) { + scaffoldMessengerState.hideCurrentSnackBar(); + + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text( + error.toString(), + style: TextStyle( + color: themeData + .colorScheme.onErrorContainer), + ), + backgroundColor: + themeData.colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + ), + ); + } }, ), Divider( @@ -219,142 +422,9 @@ class _MyPicklistsState extends State { ], )) .toList(), - ); - }); - } -} - -class SharedPicklists extends StatefulWidget { - const SharedPicklists({ - super.key, - }); - - @override - State createState() => _SharedPicklistsState(); -} - -class _SharedPicklistsState extends State { - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: lovatAPI.getSharedPicklists(), - builder: (BuildContext context, - AsyncSnapshot> snapshot) { - if (snapshot.hasError) { - if (snapshot.error is LovatAPIException) { - LovatAPIException error = snapshot.error as LovatAPIException; - - if (error.message == "Not on team") { - return const NotOnTeamMessage(); - } - } - - return FriendlyErrorView(errorMessage: snapshot.error.toString()); - } - - if (snapshot.connectionState != ConnectionState.done) { - return const Column(children: [LinearProgressIndicator()]); - } - - return ScrollablePageBody( - padding: EdgeInsets.zero, - children: snapshot.data! - .map((picklist) => Column( - children: [ - Dismissible( - onUpdate: (details) { - if ((details.reached && !details.previousReached) || - (!details.reached && details.previousReached)) { - HapticFeedback.lightImpact(); - } - }, - key: GlobalKey(), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red[900], - child: const Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.delete), - SizedBox(width: 30), - ], - ), - ), - ), - child: ListTile( - title: Text(picklist.title), - subtitle: picklist.author == null - ? null - : Text(picklist.author!), - trailing: Icon( - Icons.arrow_right, - color: Theme.of(context).colorScheme.onSurface, - ), - onTap: () { - Navigator.of(context).pushNamed( - "/shared_picklist", - arguments: { - 'picklist': picklist, - }, - ); - }, - ), - onDismissed: (direction) async { - final scaffoldMessengerState = - ScaffoldMessenger.of(context); - final themeData = Theme.of(context); - - try { - scaffoldMessengerState.showSnackBar( - const SnackBar( - content: Text("Deleting..."), - behavior: SnackBarBehavior.floating, - ), - ); - - await lovatAPI.deleteSharedPicklist(picklist.id); - - scaffoldMessengerState.hideCurrentSnackBar(); - - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text( - 'Successfully deleted picklist "${picklist.title}"', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } catch (error) { - scaffoldMessengerState.hideCurrentSnackBar(); - - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text( - error.toString(), - style: TextStyle( - color: themeData - .colorScheme.onErrorContainer), - ), - backgroundColor: - themeData.colorScheme.errorContainer, - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - ), - Divider( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - height: 0, - ), - ], - )) - .toList(), - ); - }, + ), + ), + ], ); } } @@ -393,45 +463,91 @@ class MutablePicklists extends StatefulWidget { } class _MutablePicklistsState extends State { + List? picklistsMeta; + String? error; + bool isRefreshing = false; bool loading = false; @override - Widget build(BuildContext context) { - return realListsWithPermission(); + void initState() { + super.initState(); + fetchData(); } - FutureBuilder> realListsWithPermission() { - return FutureBuilder>( - future: lovatAPI.getMutablePicklists(), - builder: (context, snapshot) { - if (snapshot.hasError) { - if (snapshot.error is LovatAPIException) { - LovatAPIException error = snapshot.error as LovatAPIException; - - if (error.message == "Not on team") { - return const NotOnTeamMessage(); - } - } - - return FriendlyErrorView(errorMessage: snapshot.error.toString()); - } - - if (snapshot.connectionState != ConnectionState.done) { - return const PageBody( - padding: EdgeInsets.zero, - child: Column( - children: [ - LinearProgressIndicator(), - ], - ), - ); - } + Future fetchData() async { + final cached = lovatAPI.getCachedMutablePicklists(); + if (cached != null && picklistsMeta == null && error == null) { + setState(() { + picklistsMeta = cached; + }); + } + + setState(() { + isRefreshing = true; + }); - List picklistsMeta = snapshot.data!; + try { + final data = await lovatAPI.getMutablePicklists(); + setState(() { + picklistsMeta = data; + error = null; + }); + } on LovatAPIException catch (e) { + if (e.message == "Not on team" && picklistsMeta == null) { + setState(() { + error = "Not on team"; + }); + } else if (picklistsMeta == null) { + setState(() { + error = e.toString(); + }); + } + } catch (e) { + if (picklistsMeta == null) { + setState(() { + error = e.toString(); + }); + } + } finally { + setState(() { + isRefreshing = false; + }); + } + } - return ScrollablePageBody( + @override + Widget build(BuildContext context) { + if (error != null && picklistsMeta == null) { + if (error == "Not on team") { + return const NotOnTeamMessage(); + } + return FriendlyErrorView( + errorMessage: error!, + onRetry: fetchData, + ); + } + + if (picklistsMeta == null) { + return const PageBody( + padding: EdgeInsets.zero, + child: Column( + children: [ + LinearProgressIndicator(), + ], + ), + ); + } + + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: picklistsMeta != null, + ), + Expanded( + child: ScrollablePageBody( padding: EdgeInsets.zero, - children: picklistsMeta + children: picklistsMeta! .map((picklistMeta) => Column( children: [ Dismissible( @@ -513,6 +629,11 @@ class _MutablePicklistsState extends State { try { await picklistMeta.delete(); + setState(() { + picklistsMeta!.removeWhere( + (p) => p.uuid == picklistMeta.uuid); + }); + scaffoldMessengerState.hideCurrentSnackBar(); scaffoldMessengerState @@ -549,7 +670,9 @@ class _MutablePicklistsState extends State { ], )) .toList(), - ); - }); + ), + ), + ], + ); } } diff --git a/lib/pages/picklist/shared_picklist.dart b/lib/pages/picklist/shared_picklist.dart index 32432c19..1271ce45 100644 --- a/lib/pages/picklist/shared_picklist.dart +++ b/lib/pages/picklist/shared_picklist.dart @@ -5,7 +5,9 @@ import 'package:scouting_dashboard_app/reusable/flag_models.dart'; import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/get_picklist_analysis.dart'; +import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; class SharedPicklistPage extends StatelessWidget { @@ -123,28 +125,50 @@ class _SharedPicklistViewState extends State { List? data; List? flags; String? error; + bool isRefreshing = false; Future fetchData() async { + final fetchedFlags = await getPicklistFlags(); + final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); + + final cachedPicklist = + lovatAPI.getCachedSharedPicklistById(widget.picklistMeta.id); + if (cachedPicklist != null && data == null && error == null) { + final cachedAnalysis = + lovatAPI.getCachedPicklistAnalysis(flagPaths, cachedPicklist.weights); + if (cachedAnalysis != null) { + setState(() { + flags = fetchedFlags; + data = cachedAnalysis; + }); + } + } + setState(() { - data = null; - flags = null; - error = null; + isRefreshing = true; }); try { - final fetchedFlags = await getPicklistFlags(); - final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); final picklist = await widget.picklistMeta.getPicklist(); final result = await lovatAPI.getPicklistAnalysis(flagPaths, picklist.weights); setState(() { flags = fetchedFlags; data = result; + error = null; }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (data == null) { + setState(() => error = e.message); + } } catch (_) { - setState(() => error = "Failed to load picklist"); + if (data == null) { + setState(() => error = "Failed to load picklist"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -156,7 +180,7 @@ class _SharedPicklistViewState extends State { @override Widget build(BuildContext context) { - if (error != null) { + if (error != null && data == null) { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } @@ -168,57 +192,72 @@ class _SharedPicklistViewState extends State { final result = data!; - return ListView( - children: result - .map((teamData) => ListTile( - title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlagRow( - flags!, - Map.fromEntries( - teamData.flags.map((e) => MapEntry(e.type, e.result)), - ), - teamData.teamNumber, - onEdit: fetchData, - ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - "/picklist_team_breakdown", - arguments: { - 'team': teamData.teamNumber, - 'breakdown': teamData.zScoresWeighted, - 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklistMeta.title, - }); - }, - icon: Icon( - Icons.balance, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: "View ${teamData.teamNumber}'s z-scores", - ), - IconButton( - onPressed: () { - Navigator.of(context) - .pushNamed("/team_lookup", arguments: { - 'team': teamData.teamNumber, - }); - }, - icon: Icon( - Icons.arrow_right, - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), + Expanded( + child: ListView( + children: result + .map((teamData) => ListTile( + title: Text(teamData.teamNumber.toString()), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlagRow( + flags!, + Map.fromEntries( + teamData.flags + .map((e) => MapEntry(e.type, e.result)), + ), + teamData.teamNumber, + onEdit: fetchData, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + "/picklist_team_breakdown", + arguments: { + 'team': teamData.teamNumber, + 'breakdown': teamData.zScoresWeighted, + 'unweighted': teamData.zScoresUnweighted, + 'picklistTitle': widget.picklistMeta.title, + }); + }, + icon: Icon( + Icons.balance, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + tooltip: "View ${teamData.teamNumber}'s z-scores", + ), + IconButton( + onPressed: () { + Navigator.of(context) + .pushNamed("/team_lookup", arguments: { + 'team': teamData.teamNumber, + }); + }, + icon: Icon( + Icons.arrow_right, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + tooltip: + "Open team lookup for ${teamData.teamNumber}", + ), + ], ), - tooltip: - "Open team lookup for ${teamData.teamNumber}", - ), - ], - ), - )) - .toList(), + )) + .toList(), + ), + ), + ], ); } } diff --git a/lib/pages/picklist/view_picklist_weights.dart b/lib/pages/picklist/view_picklist_weights.dart index 1901a467..6b6471bf 100644 --- a/lib/pages/picklist/view_picklist_weights.dart +++ b/lib/pages/picklist/view_picklist_weights.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; +import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; class ViewPicklistWeightsPage extends StatefulWidget { @@ -18,20 +20,27 @@ class _ViewPicklistWeightsPageState extends State { String? error; Future fetchPicklist() async { - setState(() { - error = null; - }); + final cached = + lovatAPI.getCachedSharedPicklistById(widget.picklistMeta.id); + if (cached != null && picklist == null && error == null) { + setState(() { + picklist = cached; + }); + } try { final fetchedPicklist = await widget.picklistMeta.getPicklist(); setState(() { picklist = fetchedPicklist; + error = null; }); } catch (error) { - setState(() { - this.error = error.toString(); - }); + if (picklist == null) { + setState(() { + this.error = error.toString(); + }); + } } } diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index 23e95d25..028dcab0 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -14,6 +14,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/scouter_schedule/updat import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; class EditScoutSchedulePage extends StatefulWidget { @@ -26,17 +27,40 @@ class EditScoutSchedulePage extends StatefulWidget { class _EditScoutSchedulePageState extends State { ServerScoutSchedule? scoutSchedule; String? error; + bool isRefreshing = false; Future fetchData() async { + final tournament = Tournament.currentSync ?? await Tournament.getCurrent(); + + if (tournament != null) { + final cached = lovatAPI.getCachedScouterSchedule(tournament.key); + if (cached != null && scoutSchedule == null && error == null) { + setState(() { + scoutSchedule = cached; + }); + } + } + + setState(() { + isRefreshing = true; + }); + try { - final scoutSchedule = await lovatAPI.getScouterSchedule(); + final data = await lovatAPI.getScouterSchedule(); setState(() { - this.scoutSchedule = scoutSchedule; + scoutSchedule = data; + error = null; }); } catch (e) { + if (scoutSchedule == null) { + setState(() { + error = "Failed to load scout schedule"; + }); + } + } finally { setState(() { - error = "Failed to load scout schedule"; + isRefreshing = false; }); } } @@ -53,11 +77,32 @@ class _EditScoutSchedulePageState extends State { itemBuilder: (context, index) => SkeletonListTile(), ); + if (error != null && scoutSchedule == null) { + body = FriendlyErrorView( + errorMessage: error!, + retryLabel: "Reload", + onRetry: () async { + setState(() { + error = null; + }); + + await fetchData(); + }, + ); + } + if (scoutSchedule != null) { - body = ListView.builder( - itemBuilder: (context, index) { - final shift = scoutSchedule!.shifts[index]; - return Dismissible( + body = Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: scoutSchedule != null, + ), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final shift = scoutSchedule!.shifts[index]; + return Dismissible( key: Key(shift.id), direction: DismissDirection.endToStart, background: Container( @@ -79,18 +124,29 @@ class _EditScoutSchedulePageState extends State { } }, onDismissed: (direction) async { - try { - setState(() { - error = null; - scoutSchedule = null; - }); + final removedShifts = + List.from(scoutSchedule!.shifts); + removedShifts.removeWhere((s) => s.id == shift.id); + + setState(() { + error = null; + isRefreshing = true; + scoutSchedule!.shifts = removedShifts; + }); + try { await lovatAPI.deleteScoutScheduleShift(shift); await fetchData(); } catch (e) { + if (scoutSchedule == null) { + setState(() { + error = "Failed to delete shift"; + }); + } + } finally { setState(() { - error = "Failed to delete shift"; + isRefreshing = false; }); } }, @@ -106,19 +162,27 @@ class _EditScoutSchedulePageState extends State { try { setState(() { error = null; - scoutSchedule = null; + isRefreshing = true; }); await lovatAPI.updateScouterScheduleShift(shift); await fetchData(); } on LovatAPIException catch (e) { - setState(() { - error = e.message; - }); + if (scoutSchedule == null) { + setState(() { + error = e.message; + }); + } } catch (_) { + if (scoutSchedule == null) { + setState(() { + error = "Failed to update shift"; + }); + } + } finally { setState(() { - error = "Failed to update shift"; + isRefreshing = false; }); } } else { @@ -132,21 +196,9 @@ class _EditScoutSchedulePageState extends State { ); }, itemCount: scoutSchedule!.shifts.length, - ); - } - - if (error != null) { - body = FriendlyErrorView( - errorMessage: error!, - retryLabel: "Reload", - onRetry: () async { - setState(() { - error = null; - scoutSchedule = null; - }); - - await fetchData(); - }, + ), + ), + ], ); } @@ -164,19 +216,27 @@ class _EditScoutSchedulePageState extends State { try { setState(() { error = null; - scoutSchedule = null; + isRefreshing = true; }); await lovatAPI.createScoutScheduleShift(shift); await fetchData(); } on LovatAPIException catch (e) { - setState(() { - error = e.message; - }); + if (scoutSchedule == null) { + setState(() { + error = e.message; + }); + } } catch (_) { + if (scoutSchedule == null) { + setState(() { + error = "Failed to create shift"; + }); + } + } finally { setState(() { - error = "Failed to create shift"; + isRefreshing = false; }); } }, @@ -218,18 +278,25 @@ class _ScoutShiftEditorState extends State { TextEditingController endFieldController = TextEditingController(); Future fetchData() async { - try { + final cached = lovatAPI.getCachedScouts(); + if (cached != null && allScouts == null && errorMessage == null) { setState(() { - errorMessage = null; + allScouts = cached; }); - final allScouts = await lovatAPI.getScouts(); + } + + try { + final data = await lovatAPI.getScouts(); setState(() { - this.allScouts = allScouts; + allScouts = data; + errorMessage = null; }); } catch (e) { - setState(() { - errorMessage = "Failed to load scouts"; - }); + if (allScouts == null) { + setState(() { + errorMessage = "Failed to load scouts"; + }); + } } } @@ -256,13 +323,12 @@ class _ScoutShiftEditorState extends State { @override Widget build(BuildContext context) { - if (errorMessage != null) { + if (errorMessage != null && allScouts == null) { return FriendlyErrorView( errorMessage: errorMessage!, onRetry: () async { setState(() { errorMessage = null; - allScouts = null; }); await fetchData(); diff --git a/lib/pages/scouters.dart b/lib/pages/scouters.dart index bb723c50..fad8b5d4 100644 --- a/lib/pages/scouters.dart +++ b/lib/pages/scouters.dart @@ -13,6 +13,7 @@ import 'package:scouting_dashboard_app/reusable/navigation_drawer.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; class ScoutersPage extends StatefulWidget { @@ -26,28 +27,53 @@ class _ScoutersPageState extends State { List? scouterOverviews; String? error; Tournament? tournament; + bool isRefreshing = false; String filterText = ''; Future fetchData() async { - try { + final t = Tournament.currentSync ?? await Tournament.getCurrent(); + + setState(() { + tournament = t; + }); + + // Show stale data from cache immediately + final cached = lovatAPI.getCachedScouterOverviews( + tournamentKey: t?.key, + archivedScouters: false, + ); + if (cached != null && scouterOverviews == null && error == null) { setState(() { - scouterOverviews = null; - error = null; + scouterOverviews = cached; }); - final t = await Tournament.getCurrent(); + } + + setState(() { + isRefreshing = true; + }); + + try { final data = await lovatAPI.getScouterOverviews(); setState(() { scouterOverviews = data; - tournament = t; + error = null; }); } on LovatAPIException catch (e) { - setState(() { - error = e.message; - }); + if (scouterOverviews == null) { + setState(() { + error = e.message; + }); + } } catch (_) { + if (scouterOverviews == null) { + setState(() { + error = "Failed to load scouters"; + }); + } + } finally { setState(() { - error = "Failed to load scouters"; + isRefreshing = false; }); } } @@ -148,7 +174,7 @@ class _ScoutersPageState extends State { } } - if (error != null) { + if (error != null && scouterOverviews == null) { body = FriendlyErrorView(errorMessage: error, onRetry: fetchData); } @@ -166,22 +192,31 @@ class _ScoutersPageState extends State { tooltip: "View archived scouters") ], bottom: PreferredSize( - preferredSize: const Size.fromHeight(80), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - onChanged: (text) { - setState(() { - filterText = text; - }); - }, - decoration: const InputDecoration( - filled: true, - labelText: "Search", + preferredSize: const Size.fromHeight(84), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + onChanged: (text) { + setState(() { + filterText = text; + }); + }, + decoration: const InputDecoration( + filled: true, + labelText: "Search", + ), + autofocus: false, + ), ), - autofocus: false, - ), - ))), + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: scouterOverviews != null, + ), + ], + )), + ), drawer: const GlobalNavigationDrawer(), floatingActionButton: FloatingActionButton( onPressed: () { diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 42754a81..561698b4 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -93,7 +93,7 @@ class _SettingsPageState extends State { }); }, ), - if (cachedUserProfile?.role == UserRole.scoutingLead && + if (lovatAPI.getCachedUserProfile()?.role == UserRole.scoutingLead && selectedTournament != null) ...[ const SizedBox(height: 14), FilledButton.tonalIcon( @@ -104,7 +104,7 @@ class _SettingsPageState extends State { label: const Text("Export CSV"), ), ], - if (cachedUserProfile?.role == UserRole.scoutingLead) + if (lovatAPI.getCachedUserProfile()?.role == UserRole.scoutingLead) const EmailBox(), const AnalystsBox(), const SizedBox(height: 40), diff --git a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart index e0b7a966..7b8ef04c 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart @@ -6,6 +6,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; @@ -22,21 +23,40 @@ class TeamLookupBreakdownsTab extends StatefulWidget { class _TeamLookupBreakdownsTabState extends State { BreakdownMetrics? data; String? error; + bool isRefreshing = false; Future fetchData() async { + // Show stale data from cache immediately + final cached = lovatAPI.getCachedBreakdownMetricsByTeamNumber(widget.team); + if (cached != null && data == null && error == null) { + setState(() { + data = cached; + }); + } + setState(() { - data = null; - error = null; + isRefreshing = true; }); try { final result = await lovatAPI.getBreakdownMetricsByTeamNumber(widget.team); - setState(() => data = result); + setState(() { + data = result; + error = null; + }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (data == null) { + setState(() => error = e.message); + } } catch (_) { - setState(() => error = "Failed to load breakdowns"); + if (data == null) { + setState(() => error = "Failed to load breakdowns"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -49,11 +69,41 @@ class _TeamLookupBreakdownsTabState extends State { @override void didUpdateWidget(TeamLookupBreakdownsTab oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.team != widget.team) fetchData(); + if (oldWidget.team != widget.team) { + setState(() { + data = null; + error = null; + }); + fetchData(); + } } @override Widget build(BuildContext context) { + if (data != null) { + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), + Expanded( + child: ScrollablePageBody( + children: breakdowns + .map( + (BreakdownData breakdownData) => Breakdown( + dataIdentity: breakdownData, + data: data!, + team: widget.team, + ), + ) + .toList(), + ), + ), + ], + ); + } + if (error != null) { if (error!.contains("NO_DATA_FOR_TEAM")) { return PageBody( @@ -97,37 +147,23 @@ class _TeamLookupBreakdownsTabState extends State { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } - if (data == null) { - return PageBody( - bottom: false, - padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - child: SkeletonListView( - itemCount: breakdowns.length, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: SizedBox( - height: 118, - child: SkeletonAvatar( - style: SkeletonAvatarStyle( - borderRadius: BorderRadius.circular(10), - ), + return PageBody( + bottom: false, + padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + child: SkeletonListView( + itemCount: breakdowns.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: SizedBox( + height: 118, + child: SkeletonAvatar( + style: SkeletonAvatarStyle( + borderRadius: BorderRadius.circular(10), ), ), ), ), - ); - } - - return ScrollablePageBody( - children: breakdowns - .map( - (BreakdownData breakdownData) => Breakdown( - dataIdentity: breakdownData, - data: data!, - team: widget.team, - ), - ) - .toList(), + ), ); } } diff --git a/lib/pages/team_lookup/tabs/team_lookup_categories.dart b/lib/pages/team_lookup/tabs/team_lookup_categories.dart index 4ad53130..86706c1e 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_categories.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_categories.dart @@ -4,6 +4,7 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_category_metrics.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; @@ -20,21 +21,40 @@ class TeamLookupCategoriesTab extends StatefulWidget { class _TeamLookupCategoriesTabState extends State { CategoryMetrics? data; String? error; + bool isRefreshing = false; Future fetchData() async { + // Show stale data from cache immediately + final cached = lovatAPI.getCachedCategoryMetricsByTeamNumber(widget.team); + if (cached != null && data == null && error == null) { + setState(() { + data = cached; + }); + } + setState(() { - data = null; - error = null; + isRefreshing = true; }); try { final result = await lovatAPI.getCategoryMetricsByTeamNumber(widget.team); - setState(() => data = result); + setState(() { + data = result; + error = null; + }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (data == null) { + setState(() => error = e.message); + } } catch (e) { debugPrint(e.toString()); - setState(() => error = "Failed to load metrics"); + if (data == null) { + setState(() => error = "Failed to load metrics"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -47,11 +67,80 @@ class _TeamLookupCategoriesTabState extends State { @override void didUpdateWidget(TeamLookupCategoriesTab oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.team != widget.team) fetchData(); + if (oldWidget.team != widget.team) { + setState(() { + data = null; + error = null; + }); + fetchData(); + } } @override Widget build(BuildContext context) { + if (data != null) { + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), + Expanded( + child: ScrollablePageBody( + children: [ + MetricCategoryList( + metricCategories: metricCategories + .map((category) => MetricCategory( + categoryName: category.localizedName, + metricTiles: category.metrics + .where((metric) => metric.hideOverview == false) + .map( + (metric) => MetricTile( + value: (() { + try { + return metric.valueVizualizationBuilder( + data!.valueForMetric(metric)); + } catch (_) { + return "--"; + } + })(), + label: metric.abbreviatedLocalizedName, + onTap: metric.hideDetails + ? null + : () { + Navigator.of(context).pushNamed( + "/team_lookup_details", + arguments: { + 'category': category, + 'metric': metric.path, + 'team': widget.team, + }); + }, + ), + ) + .toList(), + onTap: category.metrics + .where((metric) => !metric.hideDetails) + .isEmpty + ? null + : () { + Navigator.of(context) + .pushNamed("/team_lookup_details", + arguments: { + 'category': category, + 'team': widget.team, + }); + }, + )) + .toList(), + ), + ], + ), + ), + ], + ); + } + if (error != null) { if (error!.contains("NO_DATA_FOR_TEAM")) { return PageBody( @@ -95,77 +184,25 @@ class _TeamLookupCategoriesTabState extends State { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } - if (data == null) { - return PageBody( - bottom: false, - padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), - child: SkeletonListView( - itemCount: metricCategories.length, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 15), - child: SizedBox( - height: 117, - child: SkeletonAvatar( - style: SkeletonAvatarStyle( - borderRadius: BorderRadius.circular(10), - ), + return PageBody( + bottom: false, + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), + child: SkeletonListView( + itemCount: metricCategories.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 15), + child: SizedBox( + height: 117, + child: SkeletonAvatar( + style: SkeletonAvatarStyle( + borderRadius: BorderRadius.circular(10), ), ), ), ), - ); - } - - return ScrollablePageBody( - children: [ - MetricCategoryList( - metricCategories: metricCategories - .map((category) => MetricCategory( - categoryName: category.localizedName, - metricTiles: category.metrics - .where((metric) => metric.hideOverview == false) - .map( - (metric) => MetricTile( -value: (() { - try { - return metric.valueVizualizationBuilder( - data!.valueForMetric(metric)); - } catch (_) { - return "--"; - } - })(), - label: metric.abbreviatedLocalizedName, - onTap: metric.hideDetails - ? null - : () { - Navigator.of(context).pushNamed( - "/team_lookup_details", - arguments: { - 'category': category, - 'metric': metric.path, - 'team': widget.team, - }); - }, - ), - ) - .toList(), - onTap: category.metrics - .where((metric) => !metric.hideDetails) - .isEmpty - ? null - : () { - Navigator.of(context) - .pushNamed("/team_lookup_details", arguments: { - 'category': category, - 'team': widget.team, - }); - }, - )) - .toList(), - ), - ], + ), ); - } +} } class MetricCategoryList extends StatelessWidget { diff --git a/lib/pages/team_lookup/tabs/team_lookup_notes.dart b/lib/pages/team_lookup/tabs/team_lookup_notes.dart index e10026a9..d3ff95f3 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_notes.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_notes.dart @@ -7,6 +7,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_notes. import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; class TeamLookupNotesTab extends StatefulWidget { @@ -21,11 +22,22 @@ class TeamLookupNotesTab extends StatefulWidget { class _TeamLookupNotesTabState extends State { List? notes; String? error; + bool isRefreshing = false; Future fetchData() async { + // Show stale data from cache immediately + final cached = lovatAPI.getCachedNotesByTeamNumber(widget.team); + if (cached != null && notes == null && error == null) { + setState(() { + notes = [ + ...cached.where((e) => e.type == NoteType.breakDescription), + ...cached.where((e) => e.type != NoteType.breakDescription), + ]; + }); + } + setState(() { - notes = null; - error = null; + isRefreshing = true; }); try { @@ -35,11 +47,20 @@ class _TeamLookupNotesTabState extends State { ...fetched.where((e) => e.type == NoteType.breakDescription), ...fetched.where((e) => e.type != NoteType.breakDescription), ]; + error = null; }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (notes == null) { + setState(() => error = e.message); + } } catch (_) { - setState(() => error = "Failed to load notes"); + if (notes == null) { + setState(() => error = "Failed to load notes"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -52,66 +73,82 @@ class _TeamLookupNotesTabState extends State { @override void didUpdateWidget(TeamLookupNotesTab oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.team != widget.team) fetchData(); + if (oldWidget.team != widget.team) { + setState(() { + notes = null; + error = null; + }); + fetchData(); + } } @override Widget build(BuildContext context) { + if (notes != null) { + return Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: notes != null, + ), + Expanded( + child: notes!.isEmpty + ? SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 100), + Image.asset( + 'assets/images/no-notes-${Theme.of(context).brightness.name}.png', + width: 250, + ), + Text( + "No notes on ${widget.team}", + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ) + : ScrollablePageBody( + children: [ + NotesList( + notes: notes! + .map( + (note) => NoteWidget( + note, + onEdit: fetchData, + ), + ) + .toList(), + ), + ], + ), + ), + ], + ); + } + if (error != null) { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } - if (notes == null) { - return PageBody( - bottom: false, - padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - child: SkeletonListView( - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 20), - child: SkeletonAvatar( - style: SkeletonAvatarStyle( - borderRadius: BorderRadius.circular(10), - randomHeight: true, - minHeight: 74, - maxHeight: 160, - ), + return PageBody( + bottom: false, + padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + child: SkeletonListView( + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 20), + child: SkeletonAvatar( + style: SkeletonAvatarStyle( + borderRadius: BorderRadius.circular(10), + randomHeight: true, + minHeight: 74, + maxHeight: 160, ), ), ), - ); - } - - return notes!.isEmpty - ? SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 100), - Image.asset( - 'assets/images/no-notes-${Theme.of(context).brightness.name}.png', - width: 250, - ), - Text( - "No notes on ${widget.team}", - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ) - : ScrollablePageBody( - children: [ - NotesList( - notes: notes! - .map( - (note) => NoteWidget( - note, - onEdit: fetchData, - ), - ) - .toList(), - ), - ], - ); + ), + ); } } diff --git a/lib/pages/team_lookup/team_lookup_breakdown_details.dart b/lib/pages/team_lookup/team_lookup_breakdown_details.dart index 1c6bf280..8f30ee1f 100644 --- a/lib/pages/team_lookup/team_lookup_breakdown_details.dart +++ b/lib/pages/team_lookup/team_lookup_breakdown_details.dart @@ -6,6 +6,7 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_breakdown_details.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; class BreakdownDetailsPage extends StatefulWidget { const BreakdownDetailsPage({ @@ -24,13 +25,24 @@ class BreakdownDetailsPage extends StatefulWidget { class _BreakdownDetailsPageState extends State { BreakdownDetailsResponse? response; bool hasError = false; + bool isRefreshing = false; Future loadData() async { - try { + final cached = lovatAPI.getCachedBreakdownDetails( + widget.team, + widget.breakdownIdentity.path, + ); + if (cached != null && response == null && !hasError) { setState(() { - hasError = false; + response = cached; }); + } + + setState(() { + isRefreshing = true; + }); + try { final data = await lovatAPI.getBreakdownDetails( widget.team, widget.breakdownIdentity.path, @@ -38,11 +50,17 @@ class _BreakdownDetailsPageState extends State { setState(() { response = data; - debugPrint(response.toString()); + hasError = false; }); } catch (_) { + if (response == null) { + setState(() { + hasError = true; + }); + } + } finally { setState(() { - hasError = true; + isRefreshing = false; }); } } @@ -57,10 +75,19 @@ class _BreakdownDetailsPageState extends State { Widget build(BuildContext context) { Widget body = const Column(children: [LinearProgressIndicator()]); - if (hasError) body = FriendlyErrorView(onRetry: loadData); + if (hasError && response == null) { + body = FriendlyErrorView(onRetry: loadData); + } if (response != null) { - body = ScrollablePageBody( + body = Column( + children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: response != null, + ), + Expanded( + child: ScrollablePageBody( children: response!.matchesWithSegments .map((segment) { return Column( @@ -88,7 +115,10 @@ class _BreakdownDetailsPageState extends State { .withWidgetBetween(const Padding( padding: EdgeInsets.only(top: 14), child: Divider(height: 1), - ))); + ))), + ), + ], + ); } return Scaffold( diff --git a/lib/pages/team_lookup/team_lookup_details.dart b/lib/pages/team_lookup/team_lookup_details.dart index ab3c381f..3aa13801 100644 --- a/lib/pages/team_lookup/team_lookup_details.dart +++ b/lib/pages/team_lookup/team_lookup_details.dart @@ -5,6 +5,7 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_metric_details.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/team_auto_paths.dart'; class TeamLookupDetailsPage extends StatefulWidget { @@ -139,21 +140,40 @@ class AnalysisOverview extends StatefulWidget { class _AnalysisOverviewState extends State { MetricDetails? data; String? error; + bool isRefreshing = false; Future fetchData() async { + final cached = + lovatAPI.getCachedMetricDetails(widget.teamNumber, widget.metric.path); + if (cached != null && data == null && error == null) { + setState(() { + data = cached; + }); + } + setState(() { - data = null; - error = null; + isRefreshing = true; }); try { final result = await lovatAPI.getMetricDetails(widget.teamNumber, widget.metric.path); - setState(() => data = result); + setState(() { + data = result; + error = null; + }); } on LovatAPIException catch (e) { - setState(() => error = e.message); + if (data == null) { + setState(() => error = e.message); + } } catch (_) { - setState(() => error = "Failed to load metric details"); + if (data == null) { + setState(() => error = "Failed to load metric details"); + } + } finally { + setState(() { + isRefreshing = false; + }); } } @@ -174,7 +194,7 @@ class _AnalysisOverviewState extends State { @override Widget build(BuildContext context) { - if (error != null) { + if (error != null && data == null) { return FriendlyErrorView(errorMessage: error, onRetry: fetchData); } @@ -186,6 +206,10 @@ class _AnalysisOverviewState extends State { return Column( children: [ + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), Row(children: [ if (d.hasResult) valueBox( diff --git a/lib/reusable/flag_models.dart b/lib/reusable/flag_models.dart index 0aa9c8d5..c734b132 100644 --- a/lib/reusable/flag_models.dart +++ b/lib/reusable/flag_models.dart @@ -38,14 +38,10 @@ class FlagType { ); factory FlagType.categoryMetric(CategoryMetric metric) { - final category = - metricCategories.firstWhere((e) => e.metrics.contains(metric)); - return FlagType( metric.path, - readableName: metric.localizedName, - description: - '${metric.localizedName} metric from ${category.localizedName.toLowerCase()} category', + readableName: metric.abbreviatedNameWithUnits, + description: metric.localizedName, defaultHue: 1, visualizationBuilder: (context, data, foregroundColor, backgroundColor) => FlagTemplate( @@ -260,6 +256,14 @@ class _NetworkFlagState extends State { final scaffoldMessengerState = ScaffoldMessenger.of(context); + final cached = lovatAPI.getCachedFlag(widget.flag.type.path, widget.team); + if (cached != null) { + setState(() { + loaded = true; + data = cached; + }); + } + try { final result = await lovatAPI.getFlag(widget.flag.type.path, widget.team); @@ -268,13 +272,15 @@ class _NetworkFlagState extends State { data = result; }); } catch (error) { - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text( - "Error fetching ${widget.flag.type.readableName}: $error", + if (data == null) { + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text( + "Error fetching ${widget.flag.type.readableName}: $error", + ), ), - ), - ); + ); + } } } diff --git a/lib/reusable/lovat_api/get_alliance_analysis.dart b/lib/reusable/lovat_api/get_alliance_analysis.dart index 1fefdd28..89e0c41b 100644 --- a/lib/reusable/lovat_api/get_alliance_analysis.dart +++ b/lib/reusable/lovat_api/get_alliance_analysis.dart @@ -67,6 +67,19 @@ class AllianceAnalysis { } extension GetAllianceAnalysis on LovatAPI { + AllianceAnalysis? getCachedAllianceAnalysis(List teams) { + return getCachedData( + '/v1/analysis/alliance', + query: { + 'teamOne': teams[0].toString(), + 'teamTwo': teams[1].toString(), + 'teamThree': teams[2].toString(), + }, + parser: (json) => + AllianceAnalysis.fromJson(json as Map), + ); + } + Future getAllianceAnalysis(List teams) async { final response = await get( '/v1/analysis/alliance', diff --git a/lib/reusable/lovat_api/get_flags.dart b/lib/reusable/lovat_api/get_flags.dart index a6e5dfc2..9359f4a3 100644 --- a/lib/reusable/lovat_api/get_flags.dart +++ b/lib/reusable/lovat_api/get_flags.dart @@ -4,6 +4,23 @@ import 'package:scouting_dashboard_app/datatypes.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; extension GetFlags on LovatAPI { + List? getCachedFlags(List paths, int teamNumber) { + final tournament = Tournament.currentSync; + return getCachedData( + '/v1/analysis/flag/team/$teamNumber', + query: { + if (tournament != null) 'tournamentKey': tournament.key, + 'flags': jsonEncode(paths), + }, + parser: (json) => json as List, + ); + } + + dynamic getCachedFlag(String path, int teamNumber) { + final flags = getCachedFlags([path], teamNumber); + return flags != null && flags.isNotEmpty ? flags.first : null; + } + Future> getFlags(List paths, int teamNumber) async { final tournament = await Tournament.getCurrent(); diff --git a/lib/reusable/lovat_api/get_match_prediction.dart b/lib/reusable/lovat_api/get_match_prediction.dart index 73eca2c9..2d736347 100644 --- a/lib/reusable/lovat_api/get_match_prediction.dart +++ b/lib/reusable/lovat_api/get_match_prediction.dart @@ -32,6 +32,29 @@ class MatchPrediction { } extension GetMatchPrediction on LovatAPI { + MatchPrediction? getCachedMatchPrediction( + int red1, + int red2, + int red3, + int blue1, + int blue2, + int blue3, + ) { + return getCachedData( + '/v1/analysis/matchprediction', + query: { + 'red1': red1.toString(), + 'red2': red2.toString(), + 'red3': red3.toString(), + 'blue1': blue1.toString(), + 'blue2': blue2.toString(), + 'blue3': blue3.toString(), + }, + parser: (json) => + MatchPrediction.fromJson(json as Map), + ); + } + Future getMatchPrediction( int red1, int red2, diff --git a/lib/reusable/lovat_api/get_matches.dart b/lib/reusable/lovat_api/get_matches.dart index accb2e65..6fd430ea 100644 --- a/lib/reusable/lovat_api/get_matches.dart +++ b/lib/reusable/lovat_api/get_matches.dart @@ -6,6 +6,21 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/match.dart'; extension GetMatches on LovatAPI { + List? getCachedMatches( + String tournamentKey, { + List? teamNumbers, + }) { + return getCachedData( + "/v1/manager/matches/$tournamentKey", + query: { + if (teamNumbers != null) 'teams': jsonEncode(teamNumbers), + }, + parser: (json) => (json as List) + .map((e) => MatchScheduleMatch.fromJson(e, tournamentKey)) + .toList(), + ); + } + Future> getMatches( String tournamentKey, { List? teamNumbers, diff --git a/lib/reusable/lovat_api/get_scouter_overviews.dart b/lib/reusable/lovat_api/get_scouter_overviews.dart index ef2d5cc8..32f004e0 100644 --- a/lib/reusable/lovat_api/get_scouter_overviews.dart +++ b/lib/reusable/lovat_api/get_scouter_overviews.dart @@ -5,6 +5,22 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; extension GetScouterOverviews on LovatAPI { + List? getCachedScouterOverviews({ + String? tournamentKey, + bool archivedScouters = false, + }) { + return getCachedData( + '/v1/manager/scouterspage', + query: { + if (tournamentKey != null) 'tournamentKey': tournamentKey, + 'archived': archivedScouters.toString(), + }, + parser: (json) => (json as List) + .map((e) => ScouterOverview.fromJson(e, archived: archivedScouters)) + .toList(), + ); + } + /// archivedScouters - true: show archived scouters only, false: show unarchived scouters only Future> getScouterOverviews( {bool archivedScouters = false}) async { diff --git a/lib/reusable/lovat_api/get_scouts.dart b/lib/reusable/lovat_api/get_scouts.dart index 2a1e8750..9b05ea0c 100644 --- a/lib/reusable/lovat_api/get_scouts.dart +++ b/lib/reusable/lovat_api/get_scouts.dart @@ -4,10 +4,20 @@ import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; -List? cachedScouters; - extension GetScouts on LovatAPI { - List? get cachedScouts => cachedScouters; + List? getCachedScouts({bool archivedScouters = false}) { + final result = getCachedData( + '/v1/manager/scoutershift/scouters', + query: { + 'archived': archivedScouters.toString(), + }, + parser: (json) => (json as List) + .map((e) => Scout.fromJson(e)) + .toList(), + ); + result?.sort((a, b) => a.name.trim().compareTo(b.name.trim())); + return result; + } /// archivedScouters - true: show archived scouters only, false: show unarchived scouters only Future> getScouts({bool archivedScouters = false}) async { @@ -25,8 +35,8 @@ extension GetScouts on LovatAPI { final json = jsonDecode(response!.body) as List; - cachedScouters = json.map((e) => Scout.fromJson(e)).toList(); - cachedScouters?.sort((a, b) => a.name.trim().compareTo(b.name.trim())); - return cachedScouters!; + final scouts = json.map((e) => Scout.fromJson(e)).toList(); + scouts.sort((a, b) => a.name.trim().compareTo(b.name.trim())); + return scouts; } } diff --git a/lib/reusable/lovat_api/get_user_profile.dart b/lib/reusable/lovat_api/get_user_profile.dart index 54a95263..41d2af47 100644 --- a/lib/reusable/lovat_api/get_user_profile.dart +++ b/lib/reusable/lovat_api/get_user_profile.dart @@ -4,6 +4,14 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/user_profile.dart'; extension GetUserProfile on LovatAPI { + LovatUserProfile? getCachedUserProfile() { + return getCachedData( + '/v1/manager/profile', + parser: (json) => + LovatUserProfile.fromJson(json as Map), + ); + } + Future getUserProfile() async { final response = await get('/v1/manager/profile'); diff --git a/lib/reusable/lovat_api/lovat_api.dart b/lib/reusable/lovat_api/lovat_api.dart index 3b02ab60..abf325bb 100644 --- a/lib/reusable/lovat_api/lovat_api.dart +++ b/lib/reusable/lovat_api/lovat_api.dart @@ -6,12 +6,14 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:scouting_dashboard_app/constants.dart'; +import 'package:scouting_dashboard_app/reusable/lovat_api/response_cache.dart'; class LovatAPI { LovatAPI(this.baseUrl); String baseUrl; bool _isAuthenticating = false; + final ResponseCache cache = ResponseCache(); // Track if Auth0 Web SDK has been initialized bool _webSdkInitialized = false; @@ -134,8 +136,57 @@ class LovatAPI { Future get(String path, {Map? query}) async { final uri = Uri.parse(baseUrl + path).replace(queryParameters: query); + final key = uri.toString(); - return await http.get(uri, headers: await _getHeaders()); + final cachedEntry = cache.get(key); + final headers = await _getHeaders(); + if (cachedEntry?.etag != null) { + headers['If-None-Match'] = cachedEntry!.etag!; + } + + final response = await http.get(uri, headers: headers); + + if (response.statusCode == 304 && cachedEntry != null) { + return http.Response.bytes(utf8.encode(cachedEntry.body), 200); + } + + if (response.statusCode == 200) { + final etag = response.headers['etag'] ?? response.headers['ETag']; + cache.put(key, response.body, etag: etag); + } + + return response; + } + + http.Response? getCachedResponse( + String path, { + Map? query, + }) { + final key = _cacheKey(path, query: query); + final entry = cache.get(key); + if (entry == null) return null; + return http.Response.bytes(utf8.encode(entry.body), 200); + } + + T? getCachedData( + String path, { + Map? query, + required T Function(dynamic json) parser, + }) { + final key = _cacheKey(path, query: query); + final entry = cache.get(key); + if (entry == null) return null; + try { + return parser(jsonDecode(entry.body)); + } catch (_) { + return null; + } + } + + String _cacheKey(String path, {Map? query}) { + return Uri.parse(baseUrl + path) + .replace(queryParameters: query) + .toString(); } Future post( diff --git a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart index cd944345..2057e5d4 100644 --- a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart +++ b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart @@ -57,6 +57,30 @@ class PicklistAnalysisTeam { } extension GetPicklistAnalysis on LovatAPI { + List? getCachedPicklistAnalysis( + List flags, + List weights, + ) { + final tournament = Tournament.currentSync; + return getCachedData( + '/v1/analysis/picklist', + query: { + if (tournament != null) 'tournamentKey': tournament.key, + 'flags': jsonEncode(flags), + ...Map.fromEntries( + weights.map((e) => MapEntry(e.path, e.value.toString())).toList(), + ), + }, + parser: (json) { + final map = json as Map; + return (map['teams'] as List) + .map((team) => + PicklistAnalysisTeam.fromJson(team as Map)) + .toList(); + }, + ); + } + Future> getPicklistAnalysis( List flags, List weights, diff --git a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklist_by_id.dart b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklist_by_id.dart index a6e323d3..0665e676 100644 --- a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklist_by_id.dart +++ b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklist_by_id.dart @@ -3,6 +3,12 @@ import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; extension GetMutablePicklistById on LovatAPI { + MutablePicklist? getCachedMutablePicklistById(String id) { + final response = getCachedResponse('/v1/manager/mutablepicklists/$id'); + if (response == null) return null; + return MutablePicklist.fromJSON(response.body); + } + Future getMutablePicklistById(String id) async { final response = await get('/v1/manager/mutablepicklists/$id'); diff --git a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart index d99cb171..34368b28 100644 --- a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart +++ b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart @@ -5,6 +5,15 @@ import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; extension GetMutablePicklists on LovatAPI { + List? getCachedMutablePicklists() { + return getCachedData( + '/v1/manager/mutablepicklists', + parser: (json) => (json as List) + .map((e) => MutablePicklistMeta.fromJson(e)) + .toList(), + ); + } + Future> getMutablePicklists() async { final response = await get('/v1/manager/mutablepicklists'); diff --git a/lib/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart b/lib/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart index 3e0fdc3d..94e61a05 100644 --- a/lib/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart +++ b/lib/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart @@ -3,6 +3,12 @@ import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; extension GetSharedPicklistById on LovatAPI { + ConfiguredPicklist? getCachedSharedPicklistById(String id) { + final response = getCachedResponse('/v1/manager/picklists/$id'); + if (response == null) return null; + return ConfiguredPicklist.fromServerJSON(response.body); + } + Future getSharedPicklistById(String id) async { final response = await get('/v1/manager/picklists/$id'); diff --git a/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart b/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart index d24262aa..cdb233ea 100644 --- a/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart +++ b/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart @@ -5,6 +5,15 @@ import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; extension GetSharedPicklists on LovatAPI { + List? getCachedSharedPicklists() { + return getCachedData( + '/v1/manager/picklists', + parser: (json) => (json as List) + .map((e) => ConfiguredPicklistMeta.fromJson(e)) + .toList(), + ); + } + Future> getSharedPicklists() async { final response = await get('/v1/manager/picklists'); diff --git a/lib/reusable/lovat_api/response_cache.dart b/lib/reusable/lovat_api/response_cache.dart new file mode 100644 index 00000000..76e40a8a --- /dev/null +++ b/lib/reusable/lovat_api/response_cache.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class CacheEntry { + const CacheEntry({ + required this.body, + this.etag, + required this.timestamp, + }); + + final String body; + final String? etag; + final int timestamp; + + Map toJson() => { + 'body': body, + 'etag': etag, + 'ts': timestamp, + }; + + factory CacheEntry.fromJson(Map json) { + return CacheEntry( + body: json['body'] as String, + etag: json['etag'] as String?, + timestamp: json['ts'] as int, + ); + } +} + +class ResponseCache { + static const _prefsKey = 'lovat_api_cache'; + + final Map _cache = {}; + + bool _loaded = false; + Timer? _flushTimer; + bool _dirty = false; + + Future load() async { + if (_loaded) return; + + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + + if (raw != null) { + try { + final decoded = jsonDecode(raw) as Map; + decoded.forEach((key, value) { + _cache[key] = CacheEntry.fromJson(value as Map); + }); + } catch (_) { + } + } + + _loaded = true; + } + + CacheEntry? get(String key) { + return _cache[key]; + } + + void put(String key, String body, {String? etag}) { + final entry = CacheEntry( + body: body, + etag: etag ?? _cache[key]?.etag, + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + _cache[key] = entry; + _dirty = true; + _scheduleFlush(); + } + + void remove(String key) { + if (_cache.remove(key) != null) { + _dirty = true; + _scheduleFlush(); + } + } + + void clear() { + _cache.clear(); + _dirty = true; + _scheduleFlush(); + } + + void _scheduleFlush() { + _flushTimer?.cancel(); + _flushTimer = Timer(const Duration(milliseconds: 500), _flush); + } + + Future _flush() async { + if (!_dirty) return; + + _dirty = false; + + final encoded = jsonEncode( + _cache.map((key, value) => MapEntry(key, value.toJson())), + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, encoded); + } + + Future flush() async { + await _flush(); + } +} \ No newline at end of file diff --git a/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart b/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart index f5dac076..c5f281a3 100644 --- a/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart +++ b/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart @@ -6,6 +6,14 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; extension GetScouterSchedule on LovatAPI { + ServerScoutSchedule? getCachedScouterSchedule(String tournamentKey) { + return getCachedData( + '/v1/manager/tournament/$tournamentKey/scoutershifts', + parser: (json) => + ServerScoutSchedule.fromJson(json as Map), + ); + } + Future getScouterSchedule() async { final tournament = await Tournament.getCurrent(); diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart index 6e841e64..324b7af9 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart @@ -184,6 +184,20 @@ class BreakdownDetailsResponse { } extension GetBreakdownMetrics on LovatAPI { + BreakdownDetailsResponse? getCachedBreakdownDetails( + int teamNumber, + String breakdownPath, + ) { + return getCachedData( + '/v1/analysis/breakdown/team/$teamNumber/$breakdownPath', + parser: (json) => BreakdownDetailsResponse.fromJson( + (json as List).cast(), + teamNumber: teamNumber, + breakdownPath: breakdownPath, + ), + ); + } + Future getBreakdownDetails( int teamNumber, String breakdownPath, diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart index cb97e874..3717ce63 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart @@ -30,6 +30,14 @@ class BreakdownMetrics { } extension GetBreakdownMetrics on LovatAPI { + BreakdownMetrics? getCachedBreakdownMetricsByTeamNumber(int teamNumber) { + return getCachedData( + '/v1/analysis/breakdown/team/$teamNumber', + parser: (json) => + BreakdownMetrics.fromJson(json as Map), + ); + } + Future getBreakdownMetricsByTeamNumber( int teamNumber, ) async { diff --git a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart index 8b0acb4c..64df0ddb 100644 --- a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart @@ -18,6 +18,14 @@ class CategoryMetrics { } extension GetCategoryMetrics on LovatAPI { + CategoryMetrics? getCachedCategoryMetricsByTeamNumber(int teamNumber) { + return getCachedData( + '/v1/analysis/category/team/$teamNumber', + parser: (json) => + CategoryMetrics.fromJson(json as Map), + ); + } + Future getCategoryMetricsByTeamNumber( int teamNumber, ) async { diff --git a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart index b3755fd5..436e70bb 100644 --- a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart @@ -71,6 +71,15 @@ class MetricDetails { } extension GetMetricDetails on LovatAPI { + MetricDetails? getCachedMetricDetails( + int teamNumber, String metricPath) { + return getCachedData( + '/v1/analysis/metric/$metricPath/team/$teamNumber', + parser: (json) => + MetricDetails.fromJson(json as Map), + ); + } + Future getMetricDetails( int teamNumber, String metricPath) async { final response = diff --git a/lib/reusable/lovat_api/team_lookup/get_notes.dart b/lib/reusable/lovat_api/team_lookup/get_notes.dart index 671d80c4..9dcdad21 100644 --- a/lib/reusable/lovat_api/team_lookup/get_notes.dart +++ b/lib/reusable/lovat_api/team_lookup/get_notes.dart @@ -5,6 +5,20 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/match.dart'; extension GetNotes on LovatAPI { + List? getCachedNotesByTeamNumber(int teamNumber) { + return getCachedData( + '/v1/analysis/notes/team/$teamNumber', + parser: (json) { + final list = json as List; + final notes = []; + for (final map in list) { + notes.addAll(Note.fromJoinedMap(map as Map)); + } + return notes; + }, + ); + } + Future> getNotesByTeamNumber( int teamNumber, ) async { diff --git a/lib/reusable/navigation_drawer.dart b/lib/reusable/navigation_drawer.dart index ba70532a..c71cfe19 100644 --- a/lib/reusable/navigation_drawer.dart +++ b/lib/reusable/navigation_drawer.dart @@ -5,8 +5,6 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/user_profile.dart'; import 'package:shared_preferences/shared_preferences.dart'; -LovatUserProfile? cachedUserProfile; - class GlobalNavigationDrawer extends StatefulWidget { const GlobalNavigationDrawer({ Key? key, @@ -46,8 +44,6 @@ class _GlobalNavigationDrawerState extends State { } if (profile != null) { - cachedUserProfile = profile; - setState(() { userProfile = profile; }); @@ -63,7 +59,7 @@ class _GlobalNavigationDrawerState extends State { @override void initState() { super.initState(); - userProfile = cachedUserProfile; + userProfile = lovatAPI.getCachedUserProfile(); } @override diff --git a/lib/reusable/stale_refresh_indicator.dart b/lib/reusable/stale_refresh_indicator.dart new file mode 100644 index 00000000..129ce0de --- /dev/null +++ b/lib/reusable/stale_refresh_indicator.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class StaleRefreshIndicator extends StatefulWidget implements PreferredSizeWidget { + const StaleRefreshIndicator({ + super.key, + required this.isRefreshing, + required this.hasStaleData, + }); + + final bool isRefreshing; + final bool hasStaleData; + + @override + Size get preferredSize => const Size.fromHeight(4); + + @override + State createState() => _StaleRefreshIndicatorState(); +} + +class _StaleRefreshIndicatorState extends State { + bool _visible = false; + Timer? _timer; + + @override + void initState() { + super.initState(); + _schedule(); + } + + @override + void didUpdateWidget(StaleRefreshIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isRefreshing != widget.isRefreshing || + oldWidget.hasStaleData != widget.hasStaleData) { + _schedule(); + } + } + + void _schedule() { + _timer?.cancel(); + if (widget.isRefreshing && widget.hasStaleData) { + _visible = false; + _timer = Timer(const Duration(milliseconds: 1200), () { + if (mounted) setState(() => _visible = true); + }); + } else { + _visible = false; + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 4, + child: AnimatedOpacity( + opacity: _visible ? 1.0 : 0.0, + duration: Duration(milliseconds: _visible ? 1000 : 200), + child: const LinearProgressIndicator(), + ), + ); + } +} \ No newline at end of file From c9f6e865f84b3418e580c367b93ef1f682fd8a80 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Tue, 23 Jun 2026 22:10:24 -0700 Subject: [PATCH 02/10] Fix bad handling of special characters in API responses --- lib/reusable/lovat_api/lovat_api.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/reusable/lovat_api/lovat_api.dart b/lib/reusable/lovat_api/lovat_api.dart index abf325bb..c951a98c 100644 --- a/lib/reusable/lovat_api/lovat_api.dart +++ b/lib/reusable/lovat_api/lovat_api.dart @@ -147,7 +147,8 @@ class LovatAPI { final response = await http.get(uri, headers: headers); if (response.statusCode == 304 && cachedEntry != null) { - return http.Response.bytes(utf8.encode(cachedEntry.body), 200); + return http.Response.bytes(utf8.encode(cachedEntry.body), 200, + headers: {'content-type': 'application/json'}); } if (response.statusCode == 200) { @@ -165,7 +166,8 @@ class LovatAPI { final key = _cacheKey(path, query: query); final entry = cache.get(key); if (entry == null) return null; - return http.Response.bytes(utf8.encode(entry.body), 200); + return http.Response.bytes(utf8.encode(entry.body), 200, + headers: {'content-type': 'application/json'}); } T? getCachedData( From c5354d4d73707b443a6f08eb730a1d12fa0401e8 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Tue, 23 Jun 2026 22:52:01 -0700 Subject: [PATCH 03/10] Format code --- lib/pages/alliance.dart | 29 ++- lib/pages/match_predictor.dart | 51 +++--- lib/pages/picklist/picklist.dart | 7 +- .../picklist/picklist_team_breakdown.dart | 15 +- lib/pages/picklist/picklists.dart | 9 +- lib/pages/picklist/view_picklist_weights.dart | 3 +- .../scout_schedule/edit_scout_schedule.dart | 171 +++++++++--------- lib/pages/scouters.dart | 70 +++---- lib/pages/settings.dart | 6 +- .../tabs/team_lookup_breakdowns.dart | 12 +- .../tabs/team_lookup_categories.dart | 18 +- .../team_lookup_breakdown_details.dart | 58 +++--- .../team_lookup/team_lookup_details.dart | 13 +- .../lovat_api/get_alliance_analysis.dart | 5 +- .../lovat_api/get_match_prediction.dart | 5 +- lib/reusable/lovat_api/get_scouts.dart | 5 +- lib/reusable/lovat_api/get_user_profile.dart | 3 +- lib/reusable/lovat_api/lovat_api.dart | 4 +- .../picklists/get_picklist_analysis.dart | 8 +- lib/reusable/lovat_api/response_cache.dart | 5 +- .../lovat_api/source_data/source_teams.dart | 8 +- .../team_lookup/get_breakdown_metrics.dart | 5 +- .../team_lookup/get_category_metrics.dart | 5 +- .../team_lookup/get_metric_details.dart | 8 +- lib/reusable/stale_refresh_indicator.dart | 5 +- 25 files changed, 264 insertions(+), 264 deletions(-) diff --git a/lib/pages/alliance.dart b/lib/pages/alliance.dart index 00062204..efeba381 100644 --- a/lib/pages/alliance.dart +++ b/lib/pages/alliance.dart @@ -125,9 +125,7 @@ class _AllianceContentState extends State<_AllianceContent> { InkWell( onTap: () => { Navigator.of(context).pushNamed("/team_lookup", - arguments: { - 'team': teamData.team - }) + arguments: {'team': teamData.team}) }, child: Text( teamData.team.toString(), @@ -173,16 +171,14 @@ class _AllianceContentState extends State<_AllianceContent> { Flexible( fit: FlexFit.tight, child: ValueTile( - value: - Text(numToStringRounded(analysis.totalBallThroughput)), + value: Text(numToStringRounded(analysis.totalBallThroughput)), label: const Text('Total output'), ), ), Flexible( fit: FlexFit.tight, child: ValueTile( - value: - Text(numToStringRounded(analysis.totalFuelOutputted)), + value: Text(numToStringRounded(analysis.totalFuelOutputted)), label: const Text('Hub shots'), ), ), @@ -284,9 +280,8 @@ class _AlllianceAutoPathsState extends State borderRadius: const BorderRadius.all( Radius.circular(10)), - color: autoPathColors[widget - .data.teams - .indexOf(e)], + color: autoPathColors[ + widget.data.teams.indexOf(e)], ), ), ), @@ -323,8 +318,7 @@ class _AlllianceAutoPathsState extends State null ? "--" : numToStringRounded(selectedPaths[ - widget.data.teams - .indexOf(e)]! + widget.data.teams.indexOf(e)]! .scores .cast() .average()), @@ -444,7 +438,11 @@ Container reefStack( Color? backgroundColor, Color? foregroundColor, }) { - final startTimeLists = [analysis.l1StartTime, analysis.l2StartTime, analysis.l3StartTime]; + final startTimeLists = [ + analysis.l1StartTime, + analysis.l2StartTime, + analysis.l3StartTime + ]; return Container( decoration: BoxDecoration( @@ -516,8 +514,7 @@ Container reefStack( child: Text( (() { final list = startTimeLists[row]; - if (list.length <= col || - list[col] == null) { + if (list.length <= col || list[col] == null) { return '--'; } return '${numToStringRounded(list[col])}s'; @@ -539,4 +536,4 @@ Container reefStack( ], ), ); -} \ No newline at end of file +} diff --git a/lib/pages/match_predictor.dart b/lib/pages/match_predictor.dart index 12e43737..27589399 100644 --- a/lib/pages/match_predictor.dart +++ b/lib/pages/match_predictor.dart @@ -31,7 +31,12 @@ class _MatchPredictorPageState extends State { Future fetchData() async { // Show stale data from cache immediately final cached = lovatAPI.getCachedMatchPrediction( - _teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5], + _teams[0], + _teams[1], + _teams[2], + _teams[3], + _teams[4], + _teams[5], ); if (cached != null && prediction == null && error == null) { setState(() { @@ -45,7 +50,12 @@ class _MatchPredictorPageState extends State { try { final result = await lovatAPI.getMatchPrediction( - _teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5], + _teams[0], + _teams[1], + _teams[2], + _teams[3], + _teams[4], + _teams[5], ); setState(() { prediction = result; @@ -90,7 +100,10 @@ class _MatchPredictorPageState extends State { int.parse(args['blue2']), int.parse(args['blue3']), ]; - if (prediction == null && error == null && !isRefreshing && !_notEnoughData) { + if (prediction == null && + error == null && + !isRefreshing && + !_notEnoughData) { fetchData(); } } @@ -196,9 +209,9 @@ class _MatchPredictorPageState extends State { appBar: AppBar( title: const Text("Match Predictor"), bottom: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: prediction != null, - ), + isRefreshing: isRefreshing, + hasStaleData: prediction != null, + ), ), body: SafeArea( bottom: false, @@ -281,24 +294,21 @@ class _MatchPredictorPageState extends State { child: InkWell( onTap: () => Navigator.of(context).pushNamed( "/team_lookup", - arguments: { - 'team': e.team - }), + arguments: {'team': e.team}), child: Container( decoration: BoxDecoration( color: [ Theme.of(context).colorScheme.redAlliance, Theme.of(context).colorScheme.blueAlliance ][alliance], - borderRadius: const BorderRadius.all( - Radius.circular(10))), + borderRadius: + const BorderRadius.all(Radius.circular(10))), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ Row( - mainAxisAlignment: - MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, children: [ if (role != null) Tooltip( @@ -330,9 +340,8 @@ class _MatchPredictorPageState extends State { ), Text( numToStringRounded(e.averagePoints), - style: Theme.of(context) - .textTheme - .titleMedium, + style: + Theme.of(context).textTheme.titleMedium, ) ], ), @@ -391,16 +400,16 @@ class _MatchPredictorPageState extends State { Flexible( fit: FlexFit.tight, child: ValueTile( - value: Text( - numToStringRounded(allianceData.totalBallThroughput)), + value: + Text(numToStringRounded(allianceData.totalBallThroughput)), label: const Text('Total output'), ), ), Flexible( fit: FlexFit.tight, child: ValueTile( - value: Text( - numToStringRounded(allianceData.totalFuelOutputted)), + value: + Text(numToStringRounded(allianceData.totalFuelOutputted)), label: const Text('Hub shots'), ), ), @@ -513,4 +522,4 @@ class WinningPrediction extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/picklist/picklist.dart b/lib/pages/picklist/picklist.dart index fcafa93c..a6b7b113 100644 --- a/lib/pages/picklist/picklist.dart +++ b/lib/pages/picklist/picklist.dart @@ -161,8 +161,8 @@ class _PicklistViewState extends State { final fetchedFlags = await getPicklistFlags(); final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); - final cached = lovatAPI.getCachedPicklistAnalysis( - flagPaths, widget.picklist.weights); + final cached = + lovatAPI.getCachedPicklistAnalysis(flagPaths, widget.picklist.weights); if (cached != null && data == null && error == null) { setState(() { flags = fetchedFlags; @@ -258,8 +258,7 @@ class _PicklistViewState extends State { .colorScheme .onSurfaceVariant, ), - tooltip: - "View ${teamData.teamNumber}'s z-scores", + tooltip: "View ${teamData.teamNumber}'s z-scores", ), IconButton( onPressed: () { diff --git a/lib/pages/picklist/picklist_team_breakdown.dart b/lib/pages/picklist/picklist_team_breakdown.dart index 3469dec6..8a1fd5c9 100644 --- a/lib/pages/picklist/picklist_team_breakdown.dart +++ b/lib/pages/picklist/picklist_team_breakdown.dart @@ -25,13 +25,14 @@ class _PicklistTeamBreakdownPageState extends State { String teamNumber = routeArgs['team'].toString(); String picklistTitle = routeArgs['picklistTitle']; List breakdown = - (routeArgs['breakdown'] as List).cast(); + (routeArgs['breakdown'] as List) + .cast(); List unweightedBreakdown = - (routeArgs['unweighted'] as List).cast(); + (routeArgs['unweighted'] as List) + .cast(); - unweightedBreakdown - .removeWhere((e) => e.result == 0); + unweightedBreakdown.removeWhere((e) => e.result == 0); unweightedBreakdown.sort((a, b) => b.result.compareTo(a.result)); breakdown.removeWhere((e) => e.result == 0); @@ -101,8 +102,8 @@ class _PicklistTeamBreakdownPageState extends State { Text( picklistWeights .firstWhere((e) => e.path == weight.type, - orElse: () => PicklistWeight( - weight.type, weight.type)) + orElse: () => + PicklistWeight(weight.type, weight.type)) .localizedName, overflow: TextOverflow.clip, style: Theme.of(context).textTheme.titleMedium!.merge( @@ -135,4 +136,4 @@ class _PicklistTeamBreakdownPageState extends State { )), ); } -} \ No newline at end of file +} diff --git a/lib/pages/picklist/picklists.dart b/lib/pages/picklist/picklists.dart index e4b200f3..8411461b 100644 --- a/lib/pages/picklist/picklists.dart +++ b/lib/pages/picklist/picklists.dart @@ -224,9 +224,8 @@ class _MyPicklistsState extends State { }, ), Divider( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, height: 0, ), ], @@ -380,8 +379,8 @@ class _SharedPicklistsState extends State { await lovatAPI.deleteSharedPicklist(picklist.id); setState(() { - picklists!.removeWhere( - (p) => p.id == picklist.id); + picklists! + .removeWhere((p) => p.id == picklist.id); }); scaffoldMessengerState.hideCurrentSnackBar(); diff --git a/lib/pages/picklist/view_picklist_weights.dart b/lib/pages/picklist/view_picklist_weights.dart index 6b6471bf..781b5f63 100644 --- a/lib/pages/picklist/view_picklist_weights.dart +++ b/lib/pages/picklist/view_picklist_weights.dart @@ -20,8 +20,7 @@ class _ViewPicklistWeightsPageState extends State { String? error; Future fetchPicklist() async { - final cached = - lovatAPI.getCachedSharedPicklistById(widget.picklistMeta.id); + final cached = lovatAPI.getCachedSharedPicklistById(widget.picklistMeta.id); if (cached != null && picklist == null && error == null) { setState(() { picklist = cached; diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index 028dcab0..fbad6d62 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -103,99 +103,100 @@ class _EditScoutSchedulePageState extends State { itemBuilder: (context, index) { final shift = scoutSchedule!.shifts[index]; return Dismissible( - key: Key(shift.id), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red[900], - child: const Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.delete), - SizedBox(width: 30), - ], - ), - ), - ), - onUpdate: (details) { - if ((details.reached && !details.previousReached) || - (!details.reached && details.previousReached)) { - HapticFeedback.lightImpact(); - } - }, - onDismissed: (direction) async { - final removedShifts = - List.from(scoutSchedule!.shifts); - removedShifts.removeWhere((s) => s.id == shift.id); + key: Key(shift.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red[900], + child: const Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon(Icons.delete), + SizedBox(width: 30), + ], + ), + ), + ), + onUpdate: (details) { + if ((details.reached && !details.previousReached) || + (!details.reached && details.previousReached)) { + HapticFeedback.lightImpact(); + } + }, + onDismissed: (direction) async { + final removedShifts = + List.from(scoutSchedule!.shifts); + removedShifts.removeWhere((s) => s.id == shift.id); - setState(() { - error = null; - isRefreshing = true; - scoutSchedule!.shifts = removedShifts; - }); + setState(() { + error = null; + isRefreshing = true; + scoutSchedule!.shifts = removedShifts; + }); - try { - await lovatAPI.deleteScoutScheduleShift(shift); + try { + await lovatAPI.deleteScoutScheduleShift(shift); - await fetchData(); - } catch (e) { - if (scoutSchedule == null) { - setState(() { - error = "Failed to delete shift"; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - }, - child: ListTile( - title: Text("${shift.start} to ${shift.end}"), - subtitle: Text(shift.allScoutsList), - onTap: () { - Navigator.of(context).pushWidget( - ScoutShiftEditor( - initialShift: shift.copy(), - onSubmit: (shift) async { - if (shift is ServerScoutingShift) { - try { - setState(() { - error = null; - isRefreshing = true; - }); - - await lovatAPI.updateScouterScheduleShift(shift); - - await fetchData(); - } on LovatAPIException catch (e) { - if (scoutSchedule == null) { - setState(() { - error = e.message; - }); - } - } catch (_) { - if (scoutSchedule == null) { - setState(() { - error = "Failed to update shift"; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } else { - throw Exception("Invalid shift type"); + await fetchData(); + } catch (e) { + if (scoutSchedule == null) { + setState(() { + error = "Failed to delete shift"; + }); } + } finally { + setState(() { + isRefreshing = false; + }); + } + }, + child: ListTile( + title: Text("${shift.start} to ${shift.end}"), + subtitle: Text(shift.allScoutsList), + onTap: () { + Navigator.of(context).pushWidget( + ScoutShiftEditor( + initialShift: shift.copy(), + onSubmit: (shift) async { + if (shift is ServerScoutingShift) { + try { + setState(() { + error = null; + isRefreshing = true; + }); + + await lovatAPI + .updateScouterScheduleShift(shift); + + await fetchData(); + } on LovatAPIException catch (e) { + if (scoutSchedule == null) { + setState(() { + error = e.message; + }); + } + } catch (_) { + if (scoutSchedule == null) { + setState(() { + error = "Failed to update shift"; + }); + } + } finally { + setState(() { + isRefreshing = false; + }); + } + } else { + throw Exception("Invalid shift type"); + } + }, + ), + ); }, ), ); }, - ), - ); - }, - itemCount: scoutSchedule!.shifts.length, + itemCount: scoutSchedule!.shifts.length, ), ), ], diff --git a/lib/pages/scouters.dart b/lib/pages/scouters.dart index fad8b5d4..c6b6733c 100644 --- a/lib/pages/scouters.dart +++ b/lib/pages/scouters.dart @@ -180,43 +180,43 @@ class _ScoutersPageState extends State { return Scaffold( appBar: AppBar( - title: const Text("Scouters"), - actions: [ - IconButton( - onPressed: () { - Navigator.of(context).pushWidget(ArchivedScoutersPage( - onChanged: () => fetchData(), - )); - }, - icon: const Icon(Icons.access_time), - tooltip: "View archived scouters") - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(84), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - onChanged: (text) { - setState(() { - filterText = text; - }); - }, - decoration: const InputDecoration( - filled: true, - labelText: "Search", - ), - autofocus: false, + title: const Text("Scouters"), + actions: [ + IconButton( + onPressed: () { + Navigator.of(context).pushWidget(ArchivedScoutersPage( + onChanged: () => fetchData(), + )); + }, + icon: const Icon(Icons.access_time), + tooltip: "View archived scouters") + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(84), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + onChanged: (text) { + setState(() { + filterText = text; + }); + }, + decoration: const InputDecoration( + filled: true, + labelText: "Search", ), + autofocus: false, ), - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: scouterOverviews != null, - ), - ], - )), - ), + ), + StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: scouterOverviews != null, + ), + ], + )), + ), drawer: const GlobalNavigationDrawer(), floatingActionButton: FloatingActionButton( onPressed: () { diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 561698b4..4c614778 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -93,7 +93,8 @@ class _SettingsPageState extends State { }); }, ), - if (lovatAPI.getCachedUserProfile()?.role == UserRole.scoutingLead && + if (lovatAPI.getCachedUserProfile()?.role == + UserRole.scoutingLead && selectedTournament != null) ...[ const SizedBox(height: 14), FilledButton.tonalIcon( @@ -104,7 +105,8 @@ class _SettingsPageState extends State { label: const Text("Export CSV"), ), ], - if (lovatAPI.getCachedUserProfile()?.role == UserRole.scoutingLead) + if (lovatAPI.getCachedUserProfile()?.role == + UserRole.scoutingLead) const EmailBox(), const AnalystsBox(), const SizedBox(height: 40), diff --git a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart index 7b8ef04c..b896e297 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart @@ -233,14 +233,12 @@ class Breakdown extends StatelessWidget { child: Row( children: dataIdentity.segments .where((segmentData) => - data.segmentValue( - dataIdentity.path, - segmentData.path) != + data.segmentValue(dataIdentity.path, + segmentData.path) != 0) .map((BreakdownSegmentData segmentData) { - final analyzedSegmentValue = - data.segmentValue( - dataIdentity.path, segmentData.path); + final analyzedSegmentValue = data.segmentValue( + dataIdentity.path, segmentData.path); return segment( context, @@ -305,4 +303,4 @@ class Breakdown extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/team_lookup/tabs/team_lookup_categories.dart b/lib/pages/team_lookup/tabs/team_lookup_categories.dart index 86706c1e..214bee6c 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_categories.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_categories.dart @@ -120,16 +120,16 @@ class _TeamLookupCategoriesTabState extends State { ) .toList(), onTap: category.metrics - .where((metric) => !metric.hideDetails) - .isEmpty + .where((metric) => !metric.hideDetails) + .isEmpty ? null : () { - Navigator.of(context) - .pushNamed("/team_lookup_details", - arguments: { - 'category': category, - 'team': widget.team, - }); + Navigator.of(context).pushNamed( + "/team_lookup_details", + arguments: { + 'category': category, + 'team': widget.team, + }); }, )) .toList(), @@ -202,7 +202,7 @@ class _TeamLookupCategoriesTabState extends State { ), ), ); -} + } } class MetricCategoryList extends StatelessWidget { diff --git a/lib/pages/team_lookup/team_lookup_breakdown_details.dart b/lib/pages/team_lookup/team_lookup_breakdown_details.dart index 8f30ee1f..fcdc3bb5 100644 --- a/lib/pages/team_lookup/team_lookup_breakdown_details.dart +++ b/lib/pages/team_lookup/team_lookup_breakdown_details.dart @@ -88,34 +88,36 @@ class _BreakdownDetailsPageState extends State { ), Expanded( child: ScrollablePageBody( - children: response!.matchesWithSegments - .map((segment) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SectionTitle(segment.segmentName), - const SizedBox(height: 8), - ...segment.matches - .map((match) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(match.matchIdentity.getLocalizedDescription( - abbreviateName: true)), - Text(match.sourceDescription), - ], - ); - }) - .toList() - .withSpaceBetween(height: 7) - ], - ); - }) - .toList() - .withWidgetBetween(const Padding( - padding: EdgeInsets.only(top: 14), - child: Divider(height: 1), - ))), + children: response!.matchesWithSegments + .map((segment) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SectionTitle(segment.segmentName), + const SizedBox(height: 8), + ...segment.matches + .map((match) { + return Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(match.matchIdentity + .getLocalizedDescription( + abbreviateName: true)), + Text(match.sourceDescription), + ], + ); + }) + .toList() + .withSpaceBetween(height: 7) + ], + ); + }) + .toList() + .withWidgetBetween(const Padding( + padding: EdgeInsets.only(top: 14), + child: Divider(height: 1), + ))), ), ], ); diff --git a/lib/pages/team_lookup/team_lookup_details.dart b/lib/pages/team_lookup/team_lookup_details.dart index 3aa13801..4c52b670 100644 --- a/lib/pages/team_lookup/team_lookup_details.dart +++ b/lib/pages/team_lookup/team_lookup_details.dart @@ -156,8 +156,8 @@ class _AnalysisOverviewState extends State { }); try { - final result = - await lovatAPI.getMetricDetails(widget.teamNumber, widget.metric.path); + final result = await lovatAPI.getMetricDetails( + widget.teamNumber, widget.metric.path); setState(() { data = result; error = null; @@ -272,8 +272,8 @@ class _AnalysisOverviewState extends State { .onPrimaryContainer, ), Text( - widget.metric.valueVizualizationBuilder( - d.difference!.abs()), + widget.metric + .valueVizualizationBuilder(d.difference!.abs()), style: Theme.of(context) .textTheme .headlineSmall! @@ -303,8 +303,7 @@ class _AnalysisOverviewState extends State { ); } - Widget sparkline( - BuildContext context, MetricDetails data, double? max) { + Widget sparkline(BuildContext context, MetricDetails data, double? max) { return Column( children: [ AspectRatio( @@ -450,4 +449,4 @@ Container valueBox(BuildContext context, Widget value, String description, ), ), ); -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/get_alliance_analysis.dart b/lib/reusable/lovat_api/get_alliance_analysis.dart index 89e0c41b..45c90272 100644 --- a/lib/reusable/lovat_api/get_alliance_analysis.dart +++ b/lib/reusable/lovat_api/get_alliance_analysis.dart @@ -75,8 +75,7 @@ extension GetAllianceAnalysis on LovatAPI { 'teamTwo': teams[1].toString(), 'teamThree': teams[2].toString(), }, - parser: (json) => - AllianceAnalysis.fromJson(json as Map), + parser: (json) => AllianceAnalysis.fromJson(json as Map), ); } @@ -99,4 +98,4 @@ extension GetAllianceAnalysis on LovatAPI { jsonDecode(response!.body) as Map, ); } -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/get_match_prediction.dart b/lib/reusable/lovat_api/get_match_prediction.dart index 2d736347..f83a4d1b 100644 --- a/lib/reusable/lovat_api/get_match_prediction.dart +++ b/lib/reusable/lovat_api/get_match_prediction.dart @@ -50,8 +50,7 @@ extension GetMatchPrediction on LovatAPI { 'blue2': blue2.toString(), 'blue3': blue3.toString(), }, - parser: (json) => - MatchPrediction.fromJson(json as Map), + parser: (json) => MatchPrediction.fromJson(json as Map), ); } @@ -88,4 +87,4 @@ extension GetMatchPrediction on LovatAPI { jsonDecode(response!.body) as Map, ); } -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/get_scouts.dart b/lib/reusable/lovat_api/get_scouts.dart index 9b05ea0c..6ca5b169 100644 --- a/lib/reusable/lovat_api/get_scouts.dart +++ b/lib/reusable/lovat_api/get_scouts.dart @@ -11,9 +11,8 @@ extension GetScouts on LovatAPI { query: { 'archived': archivedScouters.toString(), }, - parser: (json) => (json as List) - .map((e) => Scout.fromJson(e)) - .toList(), + parser: (json) => + (json as List).map((e) => Scout.fromJson(e)).toList(), ); result?.sort((a, b) => a.name.trim().compareTo(b.name.trim())); return result; diff --git a/lib/reusable/lovat_api/get_user_profile.dart b/lib/reusable/lovat_api/get_user_profile.dart index 41d2af47..3edc38f2 100644 --- a/lib/reusable/lovat_api/get_user_profile.dart +++ b/lib/reusable/lovat_api/get_user_profile.dart @@ -7,8 +7,7 @@ extension GetUserProfile on LovatAPI { LovatUserProfile? getCachedUserProfile() { return getCachedData( '/v1/manager/profile', - parser: (json) => - LovatUserProfile.fromJson(json as Map), + parser: (json) => LovatUserProfile.fromJson(json as Map), ); } diff --git a/lib/reusable/lovat_api/lovat_api.dart b/lib/reusable/lovat_api/lovat_api.dart index c951a98c..436eb198 100644 --- a/lib/reusable/lovat_api/lovat_api.dart +++ b/lib/reusable/lovat_api/lovat_api.dart @@ -186,9 +186,7 @@ class LovatAPI { } String _cacheKey(String path, {Map? query}) { - return Uri.parse(baseUrl + path) - .replace(queryParameters: query) - .toString(); + return Uri.parse(baseUrl + path).replace(queryParameters: query).toString(); } Future post( diff --git a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart index 2057e5d4..a63d4869 100644 --- a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart +++ b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart @@ -42,7 +42,8 @@ class PicklistAnalysisTeam { static PicklistAnalysisTeam fromJson(Map json) { List parseEntries(String key) { return (json[key] as List? ?? []) - .map((val) => PicklistBreakdownEntry.fromJson(val as Map)) + .map((val) => + PicklistBreakdownEntry.fromJson(val as Map)) .toList(); } @@ -106,7 +107,8 @@ extension GetPicklistAnalysis on LovatAPI { final json = jsonDecode(response!.body) as Map; return (json['teams'] as List) - .map((team) => PicklistAnalysisTeam.fromJson(team as Map)) + .map((team) => + PicklistAnalysisTeam.fromJson(team as Map)) .toList(); } @@ -139,4 +141,4 @@ extension GetPicklistAnalysis on LovatAPI { return const ListToCsvConverter().convert(rows); } -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/response_cache.dart b/lib/reusable/lovat_api/response_cache.dart index 76e40a8a..0094a358 100644 --- a/lib/reusable/lovat_api/response_cache.dart +++ b/lib/reusable/lovat_api/response_cache.dart @@ -50,8 +50,7 @@ class ResponseCache { decoded.forEach((key, value) { _cache[key] = CacheEntry.fromJson(value as Map); }); - } catch (_) { - } + } catch (_) {} } _loaded = true; @@ -106,4 +105,4 @@ class ResponseCache { Future flush() async { await _flush(); } -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/source_data/source_teams.dart b/lib/reusable/lovat_api/source_data/source_teams.dart index ae7ee3d9..6b25357b 100644 --- a/lib/reusable/lovat_api/source_data/source_teams.dart +++ b/lib/reusable/lovat_api/source_data/source_teams.dart @@ -33,9 +33,11 @@ extension SourceTeamSettings on LovatAPI { // body can be "THIS_TEAM", "ALL_TEAMS", or "[1, 2, 3]" if (response!.body == 'THIS_TEAM') { - return const SourceTeamSettingsResponse(mode: SourceTeamSettingsMode.thisTeam); + return const SourceTeamSettingsResponse( + mode: SourceTeamSettingsMode.thisTeam); } else if (response.body == 'ALL_TEAMS') { - return const SourceTeamSettingsResponse(mode: SourceTeamSettingsMode.allTeams); + return const SourceTeamSettingsResponse( + mode: SourceTeamSettingsMode.allTeams); } else { return SourceTeamSettingsResponse( mode: SourceTeamSettingsMode.specificTeams, @@ -53,4 +55,4 @@ class SourceTeamSettingsResponse { final SourceTeamSettingsMode mode; final List? teams; -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart index 3717ce63..e3b87d1c 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart @@ -33,8 +33,7 @@ extension GetBreakdownMetrics on LovatAPI { BreakdownMetrics? getCachedBreakdownMetricsByTeamNumber(int teamNumber) { return getCachedData( '/v1/analysis/breakdown/team/$teamNumber', - parser: (json) => - BreakdownMetrics.fromJson(json as Map), + parser: (json) => BreakdownMetrics.fromJson(json as Map), ); } @@ -54,4 +53,4 @@ extension GetBreakdownMetrics on LovatAPI { return BreakdownMetrics.fromJson(json); } -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart index 64df0ddb..0e7c68a2 100644 --- a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart @@ -21,8 +21,7 @@ extension GetCategoryMetrics on LovatAPI { CategoryMetrics? getCachedCategoryMetricsByTeamNumber(int teamNumber) { return getCachedData( '/v1/analysis/category/team/$teamNumber', - parser: (json) => - CategoryMetrics.fromJson(json as Map), + parser: (json) => CategoryMetrics.fromJson(json as Map), ); } @@ -51,4 +50,4 @@ extension GetCategoryMetrics on LovatAPI { } } } -} \ No newline at end of file +} diff --git a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart index 436e70bb..af6900f1 100644 --- a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart @@ -71,12 +71,10 @@ class MetricDetails { } extension GetMetricDetails on LovatAPI { - MetricDetails? getCachedMetricDetails( - int teamNumber, String metricPath) { + MetricDetails? getCachedMetricDetails(int teamNumber, String metricPath) { return getCachedData( '/v1/analysis/metric/$metricPath/team/$teamNumber', - parser: (json) => - MetricDetails.fromJson(json as Map), + parser: (json) => MetricDetails.fromJson(json as Map), ); } @@ -94,4 +92,4 @@ extension GetMetricDetails on LovatAPI { jsonDecode(response!.body) as Map, ); } -} \ No newline at end of file +} diff --git a/lib/reusable/stale_refresh_indicator.dart b/lib/reusable/stale_refresh_indicator.dart index 129ce0de..c3680846 100644 --- a/lib/reusable/stale_refresh_indicator.dart +++ b/lib/reusable/stale_refresh_indicator.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -class StaleRefreshIndicator extends StatefulWidget implements PreferredSizeWidget { +class StaleRefreshIndicator extends StatefulWidget + implements PreferredSizeWidget { const StaleRefreshIndicator({ super.key, required this.isRefreshing, @@ -67,4 +68,4 @@ class _StaleRefreshIndicatorState extends State { ), ); } -} \ No newline at end of file +} From bdb845e018045e14c1442c20e33814442f196939 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Wed, 24 Jun 2026 23:54:36 -0700 Subject: [PATCH 04/10] Fix race condition in scouter schedule updates --- .../scout_schedule/edit_scout_schedule.dart | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index fbad6d62..5dd2cf0f 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -124,8 +124,10 @@ class _EditScoutSchedulePageState extends State { } }, onDismissed: (direction) async { - final removedShifts = + final originalShifts = List.from(scoutSchedule!.shifts); + final removedShifts = + List.from(originalShifts); removedShifts.removeWhere((s) => s.id == shift.id); setState(() { @@ -139,11 +141,13 @@ class _EditScoutSchedulePageState extends State { await fetchData(); } catch (e) { - if (scoutSchedule == null) { - setState(() { - error = "Failed to delete shift"; - }); - } + setState(() { + scoutSchedule!.shifts = originalShifts; + }); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Failed to delete shift")), + ); } finally { setState(() { isRefreshing = false; @@ -170,17 +174,16 @@ class _EditScoutSchedulePageState extends State { await fetchData(); } on LovatAPIException catch (e) { - if (scoutSchedule == null) { - setState(() { - error = e.message; - }); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); } catch (_) { - if (scoutSchedule == null) { - setState(() { - error = "Failed to update shift"; - }); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Failed to update shift")), + ); } finally { setState(() { isRefreshing = false; @@ -224,17 +227,15 @@ class _EditScoutSchedulePageState extends State { await fetchData(); } on LovatAPIException catch (e) { - if (scoutSchedule == null) { - setState(() { - error = e.message; - }); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); } catch (_) { - if (scoutSchedule == null) { - setState(() { - error = "Failed to create shift"; - }); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Failed to create shift")), + ); } finally { setState(() { isRefreshing = false; From b83a46713d3df8e55a7f82598e845d0e67efa257 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Fri, 26 Jun 2026 18:23:44 -0700 Subject: [PATCH 05/10] Fix gap in UI between AppBar and Scaffold body --- lib/pages/picklist/picklist.dart | 119 +++++++++--------- lib/pages/picklist/picklists.dart | 36 +++--- lib/pages/picklist/shared_picklist.dart | 119 +++++++++--------- .../scout_schedule/edit_scout_schedule.dart | 23 ++-- .../tabs/team_lookup_breakdowns.dart | 33 ++--- .../tabs/team_lookup_categories.dart | 17 +-- .../team_lookup/tabs/team_lookup_notes.dart | 69 +++++----- .../team_lookup_breakdown_details.dart | 77 ++++++------ .../team_lookup/team_lookup_details.dart | 27 ++-- 9 files changed, 276 insertions(+), 244 deletions(-) diff --git a/lib/pages/picklist/picklist.dart b/lib/pages/picklist/picklist.dart index a6b7b113..2e9a2c79 100644 --- a/lib/pages/picklist/picklist.dart +++ b/lib/pages/picklist/picklist.dart @@ -217,69 +217,70 @@ class _PicklistViewState extends State { final result = data!; - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - Expanded( - child: ListView( - children: result - .map((teamData) => ListTile( - title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlagRow( - flags!, - Map.fromEntries( - teamData.flags - .map((e) => MapEntry(e.type, e.result)), - ), - teamData.teamNumber, - onEdit: fetchData, + ListView( + children: result + .map((teamData) => ListTile( + title: Text(teamData.teamNumber.toString()), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlagRow( + flags!, + Map.fromEntries( + teamData.flags + .map((e) => MapEntry(e.type, e.result)), ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - "/picklist_team_breakdown", - arguments: { - 'team': teamData.teamNumber, - 'breakdown': teamData.zScoresWeighted, - 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklist.meta.title, - }); - }, - icon: Icon( - Icons.balance, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - tooltip: "View ${teamData.teamNumber}'s z-scores", + teamData.teamNumber, + onEdit: fetchData, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + "/picklist_team_breakdown", + arguments: { + 'team': teamData.teamNumber, + 'breakdown': teamData.zScoresWeighted, + 'unweighted': teamData.zScoresUnweighted, + 'picklistTitle': widget.picklist.meta.title, + }); + }, + icon: Icon( + Icons.balance, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), - IconButton( - onPressed: () { - Navigator.of(context) - .pushNamed("/team_lookup", arguments: { - 'team': teamData.teamNumber, - }); - }, - icon: Icon( - Icons.arrow_right, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - tooltip: - "Open team lookup for ${teamData.teamNumber}", + tooltip: "View ${teamData.teamNumber}'s z-scores", + ), + IconButton( + onPressed: () { + Navigator.of(context) + .pushNamed("/team_lookup", arguments: { + 'team': teamData.teamNumber, + }); + }, + icon: Icon( + Icons.arrow_right, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), - ], - ), - )) - .toList(), + tooltip: + "Open team lookup for ${teamData.teamNumber}", + ), + ], + ), + )) + .toList(), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, ), ), ], diff --git a/lib/pages/picklist/picklists.dart b/lib/pages/picklist/picklists.dart index 8411461b..d7e58e2e 100644 --- a/lib/pages/picklist/picklists.dart +++ b/lib/pages/picklist/picklists.dart @@ -312,14 +312,9 @@ class _SharedPicklistsState extends State { return const Column(children: [LinearProgressIndicator()]); } - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: picklists != null, - ), - Expanded( - child: ScrollablePageBody( + ScrollablePageBody( padding: EdgeInsets.zero, children: picklists! .map((picklist) => Column( @@ -420,7 +415,14 @@ class _SharedPicklistsState extends State { ), ], )) - .toList(), + .toList()), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: picklists != null, ), ), ], @@ -537,14 +539,9 @@ class _MutablePicklistsState extends State { ); } - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: picklistsMeta != null, - ), - Expanded( - child: ScrollablePageBody( + ScrollablePageBody( padding: EdgeInsets.zero, children: picklistsMeta! .map((picklistMeta) => Column( @@ -668,7 +665,14 @@ class _MutablePicklistsState extends State { ), ], )) - .toList(), + .toList()), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: picklistsMeta != null, ), ), ], diff --git a/lib/pages/picklist/shared_picklist.dart b/lib/pages/picklist/shared_picklist.dart index 1271ce45..9d38dc86 100644 --- a/lib/pages/picklist/shared_picklist.dart +++ b/lib/pages/picklist/shared_picklist.dart @@ -192,69 +192,70 @@ class _SharedPicklistViewState extends State { final result = data!; - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - Expanded( - child: ListView( - children: result - .map((teamData) => ListTile( - title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlagRow( - flags!, - Map.fromEntries( - teamData.flags - .map((e) => MapEntry(e.type, e.result)), - ), - teamData.teamNumber, - onEdit: fetchData, + ListView( + children: result + .map((teamData) => ListTile( + title: Text(teamData.teamNumber.toString()), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlagRow( + flags!, + Map.fromEntries( + teamData.flags + .map((e) => MapEntry(e.type, e.result)), ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - "/picklist_team_breakdown", - arguments: { - 'team': teamData.teamNumber, - 'breakdown': teamData.zScoresWeighted, - 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklistMeta.title, - }); - }, - icon: Icon( - Icons.balance, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - tooltip: "View ${teamData.teamNumber}'s z-scores", + teamData.teamNumber, + onEdit: fetchData, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + "/picklist_team_breakdown", + arguments: { + 'team': teamData.teamNumber, + 'breakdown': teamData.zScoresWeighted, + 'unweighted': teamData.zScoresUnweighted, + 'picklistTitle': widget.picklistMeta.title, + }); + }, + icon: Icon( + Icons.balance, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), - IconButton( - onPressed: () { - Navigator.of(context) - .pushNamed("/team_lookup", arguments: { - 'team': teamData.teamNumber, - }); - }, - icon: Icon( - Icons.arrow_right, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - tooltip: - "Open team lookup for ${teamData.teamNumber}", + tooltip: "View ${teamData.teamNumber}'s z-scores", + ), + IconButton( + onPressed: () { + Navigator.of(context) + .pushNamed("/team_lookup", arguments: { + 'team': teamData.teamNumber, + }); + }, + icon: Icon( + Icons.arrow_right, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), - ], - ), - )) - .toList(), + tooltip: + "Open team lookup for ${teamData.teamNumber}", + ), + ], + ), + )) + .toList(), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, ), ), ], diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index 5dd2cf0f..4f8327ab 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -92,17 +92,12 @@ class _EditScoutSchedulePageState extends State { } if (scoutSchedule != null) { - body = Column( + body = Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: scoutSchedule != null, - ), - Expanded( - child: ListView.builder( - itemBuilder: (context, index) { - final shift = scoutSchedule!.shifts[index]; - return Dismissible( + ListView.builder( + itemBuilder: (context, index) { + final shift = scoutSchedule!.shifts[index]; + return Dismissible( key: Key(shift.id), direction: DismissDirection.endToStart, background: Container( @@ -200,6 +195,14 @@ class _EditScoutSchedulePageState extends State { ); }, itemCount: scoutSchedule!.shifts.length, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: scoutSchedule != null, ), ), ], diff --git a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart index b896e297..34459c82 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart @@ -81,23 +81,26 @@ class _TeamLookupBreakdownsTabState extends State { @override Widget build(BuildContext context) { if (data != null) { - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, + ScrollablePageBody( + children: breakdowns + .map( + (BreakdownData breakdownData) => Breakdown( + dataIdentity: breakdownData, + data: data!, + team: widget.team, + ), + ) + .toList(), ), - Expanded( - child: ScrollablePageBody( - children: breakdowns - .map( - (BreakdownData breakdownData) => Breakdown( - dataIdentity: breakdownData, - data: data!, - team: widget.team, - ), - ) - .toList(), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, ), ), ], diff --git a/lib/pages/team_lookup/tabs/team_lookup_categories.dart b/lib/pages/team_lookup/tabs/team_lookup_categories.dart index 214bee6c..da69bdce 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_categories.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_categories.dart @@ -79,14 +79,9 @@ class _TeamLookupCategoriesTabState extends State { @override Widget build(BuildContext context) { if (data != null) { - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - Expanded( - child: ScrollablePageBody( + ScrollablePageBody( children: [ MetricCategoryList( metricCategories: metricCategories @@ -136,6 +131,14 @@ class _TeamLookupCategoriesTabState extends State { ), ], ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, + ), ), ], ); diff --git a/lib/pages/team_lookup/tabs/team_lookup_notes.dart b/lib/pages/team_lookup/tabs/team_lookup_notes.dart index d3ff95f3..20d0eca9 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_notes.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_notes.dart @@ -85,44 +85,47 @@ class _TeamLookupNotesTabState extends State { @override Widget build(BuildContext context) { if (notes != null) { - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: notes != null, - ), - Expanded( - child: notes!.isEmpty - ? SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 100), - Image.asset( - 'assets/images/no-notes-${Theme.of(context).brightness.name}.png', - width: 250, - ), - Text( - "No notes on ${widget.team}", - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ) - : ScrollablePageBody( + notes!.isEmpty + ? SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - NotesList( - notes: notes! - .map( - (note) => NoteWidget( - note, - onEdit: fetchData, - ), - ) - .toList(), + const SizedBox(height: 100), + Image.asset( + 'assets/images/no-notes-${Theme.of(context).brightness.name}.png', + width: 250, + ), + Text( + "No notes on ${widget.team}", + style: Theme.of(context).textTheme.headlineMedium, ), ], ), + ) + : ScrollablePageBody( + children: [ + NotesList( + notes: notes! + .map( + (note) => NoteWidget( + note, + onEdit: fetchData, + ), + ) + .toList(), + ), + ], + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: notes != null, + ), ), ], ); diff --git a/lib/pages/team_lookup/team_lookup_breakdown_details.dart b/lib/pages/team_lookup/team_lookup_breakdown_details.dart index fcdc3bb5..c297a744 100644 --- a/lib/pages/team_lookup/team_lookup_breakdown_details.dart +++ b/lib/pages/team_lookup/team_lookup_breakdown_details.dart @@ -80,44 +80,47 @@ class _BreakdownDetailsPageState extends State { } if (response != null) { - body = Column( + body = Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: response != null, - ), - Expanded( - child: ScrollablePageBody( - children: response!.matchesWithSegments - .map((segment) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SectionTitle(segment.segmentName), - const SizedBox(height: 8), - ...segment.matches - .map((match) { - return Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text(match.matchIdentity - .getLocalizedDescription( - abbreviateName: true)), - Text(match.sourceDescription), - ], - ); - }) - .toList() - .withSpaceBetween(height: 7) - ], - ); - }) - .toList() - .withWidgetBetween(const Padding( - padding: EdgeInsets.only(top: 14), - child: Divider(height: 1), - ))), + ScrollablePageBody( + children: response!.matchesWithSegments + .map((segment) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SectionTitle(segment.segmentName), + const SizedBox(height: 8), + ...segment.matches + .map((match) { + return Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(match.matchIdentity + .getLocalizedDescription( + abbreviateName: true)), + Text(match.sourceDescription), + ], + ); + }) + .toList() + .withSpaceBetween(height: 7) + ], + ); + }) + .toList() + .withWidgetBetween(const Padding( + padding: EdgeInsets.only(top: 14), + child: Divider(height: 1), + ))), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: response != null, + ), ), ], ); diff --git a/lib/pages/team_lookup/team_lookup_details.dart b/lib/pages/team_lookup/team_lookup_details.dart index 4c52b670..1ef8027b 100644 --- a/lib/pages/team_lookup/team_lookup_details.dart +++ b/lib/pages/team_lookup/team_lookup_details.dart @@ -204,13 +204,12 @@ class _AnalysisOverviewState extends State { final d = data!; - return Column( + return Stack( children: [ - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - Row(children: [ + SingleChildScrollView( + child: Column( + children: [ + Row(children: [ if (d.hasResult) valueBox( context, @@ -296,9 +295,21 @@ class _AnalysisOverviewState extends State { sparkline(context, d, widget.metric.max), ], if (d.paths.isNotEmpty) - TeamAutoPaths( - autoPaths: d.paths, + TeamAutoPaths( + autoPaths: d.paths, + ), + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator( + isRefreshing: isRefreshing, + hasStaleData: data != null, ), + ), ], ); } From 5373a73d52ff453617f6c58e74bcaf0a5cabd1f4 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Sat, 27 Jun 2026 16:27:42 -0700 Subject: [PATCH 06/10] Expand usage of caching for user profile --- lib/pages/match_schedule.dart | 17 +++++++++++++---- lib/pages/settings.dart | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/pages/match_schedule.dart b/lib/pages/match_schedule.dart index fd468c81..bfd74b0f 100644 --- a/lib/pages/match_schedule.dart +++ b/lib/pages/match_schedule.dart @@ -47,11 +47,20 @@ class _MatchSchedulePageState extends State { bool fabVisible = false; Future checkRole() async { - final profile = await lovatAPI.getUserProfile(); + final cached = lovatAPI.getCachedUserProfile(); + if (cached != null && isScoutingLead == null) { + setState(() { + isScoutingLead = cached.role == UserRole.scoutingLead; + }); + } - setState(() { - isScoutingLead = profile.role == UserRole.scoutingLead; - }); + try { + final profile = await lovatAPI.getUserProfile(); + + setState(() { + isScoutingLead = profile.role == UserRole.scoutingLead; + }); + } catch (_) {} } Future checkTournament() async { diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 4c614778..c3f63192 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -212,6 +212,14 @@ class _TeamSourceSelectorState extends State { }); } + final cachedProfile = lovatAPI.getCachedUserProfile(); + if (cachedProfile != null && !thisTeamLoaded) { + setState(() { + thisTeamNumber = cachedProfile.team?.number; + thisTeamLoaded = true; + }); + } + try { final profile = await lovatAPI.getUserProfile(); final thisTeamNumber = profile.team?.number; From 36bd54aaff26d98a5dc4380af5e9dc92bc3efbfc Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Sat, 27 Jun 2026 18:02:54 -0700 Subject: [PATCH 07/10] Simplify per-page query logic --- lib/pages/alliance.dart | 81 +-- lib/pages/archived_scouters.dart | 280 +++----- lib/pages/match_predictor.dart | 300 +++----- lib/pages/match_schedule.dart | 2 +- lib/pages/picklist/picklist.dart | 202 +++--- lib/pages/picklist/picklist_models.dart | 2 +- lib/pages/picklist/picklists.dart | 653 ++++++++---------- lib/pages/picklist/shared_picklist.dart | 212 +++--- .../scout_schedule/edit_scout_schedule.dart | 366 ++++------ lib/pages/scouters.dart | 337 ++++----- .../tabs/team_lookup_breakdowns.dart | 226 +++--- .../tabs/team_lookup_categories.dart | 308 ++++----- .../team_lookup/tabs/team_lookup_notes.dart | 201 ++---- .../team_lookup_breakdown_details.dart | 168 ++--- .../team_lookup/team_lookup_details.dart | 276 +++----- lib/reusable/friendly_error_view.dart | 8 + .../lovat_api/get_alliance_analysis.dart | 53 +- .../lovat_api/get_match_prediction.dart | 75 +- .../lovat_api/get_scouter_overviews.dart | 84 +-- lib/reusable/lovat_api/lovat_api.dart | 4 + .../picklists/get_picklist_analysis.dart | 91 +-- .../mutable/get_mutable_picklists.dart | 54 +- .../shared/get_shared_picklists.dart | 62 +- .../get_scouter_schedule.dart | 48 +- .../team_lookup/get_breakdown_details.dart | 53 +- .../team_lookup/get_breakdown_metrics.dart | 43 +- .../team_lookup/get_category_metrics.dart | 61 +- .../team_lookup/get_metric_details.dart | 39 +- .../lovat_api/team_lookup/get_notes.dart | 58 +- lib/reusable/stale_refresh_builder.dart | 172 +++++ lib/reusable/stale_refresh_indicator.dart | 15 +- 31 files changed, 2004 insertions(+), 2530 deletions(-) create mode 100644 lib/reusable/stale_refresh_builder.dart diff --git a/lib/pages/alliance.dart b/lib/pages/alliance.dart index efeba381..5bca60eb 100644 --- a/lib/pages/alliance.dart +++ b/lib/pages/alliance.dart @@ -6,6 +6,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/get_alliance_analysis. import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/robot_roles.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/team_auto_paths.dart'; import 'package:scouting_dashboard_app/reusable/value_tile.dart'; @@ -18,77 +19,39 @@ class AlliancePage extends StatefulWidget { } class _AlliancePageState extends State { - AllianceAnalysis? data; - String? error; late List teams; - bool isRefreshing = false; - - Future fetchData() async { - // Show stale data from cache immediately - final cached = lovatAPI.getCachedAllianceAnalysis(teams); - if (cached != null && data == null && error == null) { - setState(() { - data = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final result = await lovatAPI.getAllianceAnalysis(teams); - setState(() { - data = result; - error = null; - }); - } on LovatAPIException catch (e) { - if (data == null) { - setState(() => error = e.message); - } - } catch (_) { - if (data == null) { - setState(() => error = "Failed to load alliance data"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } @override void didChangeDependencies() { super.didChangeDependencies(); teams = (ModalRoute.of(context)!.settings.arguments as Map)['teams']; - if (data == null && error == null && !isRefreshing) fetchData(); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Alliance"), - bottom: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - ), - body: _buildBody(), - ); - } - - Widget _buildBody() { - if (error != null && data == null) { - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } + return StaleRefreshBuilder( + query: lovatAPI.allianceAnalysis(teams), + builder: (context, result) { + final data = result.data; + Widget body; + if (result.hasError && data == null) { + body = FriendlyErrorView.result(result); + } else if (data == null) { + body = const Center(child: CircularProgressIndicator()); + } else { + body = ScrollablePageBody(children: [_AllianceContent(data: data)]); + } - if (data == null) { - return const Center(child: CircularProgressIndicator()); - } - - return ScrollablePageBody(children: [_AllianceContent(data: data!)]); + return Scaffold( + appBar: AppBar( + title: const Text("Alliance"), + bottom: StaleRefreshIndicator.result(result), + ), + body: body, + ); + }, + ); } } diff --git a/lib/pages/archived_scouters.dart b/lib/pages/archived_scouters.dart index 358a9e0d..02764f02 100644 --- a/lib/pages/archived_scouters.dart +++ b/lib/pages/archived_scouters.dart @@ -7,6 +7,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; @@ -23,185 +24,130 @@ class ArchivedScoutersPage extends StatefulWidget { } class _ArchivedScoutersPageState extends State { - List? scouterOverviews; - String? error; - Tournament? tournament; - bool isRefreshing = false; - String filterText = ''; - Future fetchData() async { - final t = Tournament.currentSync ?? await Tournament.getCurrent(); - - setState(() { - tournament = t; - }); - - // Show stale data from cache immediately - final cached = lovatAPI.getCachedScouterOverviews( - tournamentKey: t?.key, - archivedScouters: true, - ); - if (cached != null && scouterOverviews == null && error == null) { - setState(() { - scouterOverviews = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final data = await lovatAPI.getScouterOverviews(archivedScouters: true); - setState(() { - scouterOverviews = data; - error = null; - }); - } on LovatAPIException catch (e) { - if (scouterOverviews == null) { - setState(() { - error = e.message; - }); - } - } catch (_) { - if (scouterOverviews == null) { - setState(() { - error = "Failed to load scouters"; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } @override Widget build(BuildContext context) { - if (scouterOverviews == null) { - fetchData(); - } - - Widget body = SkeletonListView( - itemBuilder: (context, index) => SkeletonListTile(), - ); - final List? filteredScouters; - if (scouterOverviews != null) { - filteredScouters = scouterOverviews! - .where((scout) => - scout.scout.name.toLowerCase().contains(filterText.toLowerCase())) - .toList(); - } else { - filteredScouters = []; - } + return StaleRefreshBuilder( + query: lovatAPI.scouterOverviews(archivedScouters: true), + builder: (context, result) { + final scouterOverviews = result.data; + final tournament = Tournament.currentSync; - if (scouterOverviews != null) { - if (scouterOverviews!.isEmpty) { - body = PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/images/no_scouters.png", width: 250), - const SizedBox(height: 8), - Text( - "No archived scouters found", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - ], - ), - ); - } else if (filteredScouters.isEmpty) { - body = PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/images/no_scouters.png", width: 250), - const SizedBox(height: 8), - Text( - "No results found", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 2), - ], - ), - ); - } else { - body = ScrollablePageBody( - padding: EdgeInsets.zero, - children: filteredScouters - .map( - (scouterOverview) => ListTile( - leading: Monogram( - scouterOverview.scout.name.isNotEmpty - ? scouterOverview.scout.name - .substring(0, 1) - .toUpperCase() - : "", - ), - title: Text(scouterOverview.scout.name), - subtitle: Text(tournament == null - ? "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted" - : "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted, ${scouterOverview.missedMatches} missed"), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.of(context).pushWidget( - ScouterDetailsPage( - scouterOverview: scouterOverview, - onChanged: () { - fetchData(); - widget.onChanged?.call(); - }, - ), - ); - }, - ), - ) - .toList(), + Widget body = SkeletonListView( + itemBuilder: (context, index) => SkeletonListTile(), ); - } - } - - if (error != null && scouterOverviews == null) { - body = FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } + final List? filteredScouters; + if (scouterOverviews != null) { + filteredScouters = scouterOverviews + .where((scout) => scout.scout.name + .toLowerCase() + .contains(filterText.toLowerCase())) + .toList(); + } else { + filteredScouters = []; + } - return Scaffold( - appBar: AppBar( - title: const Text("Archived Scouters"), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(84), + if (scouterOverviews != null) { + if (scouterOverviews.isEmpty) { + body = PageBody( child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - onChanged: (text) { - setState(() { - filterText = text; - }); - }, - decoration: const InputDecoration( - filled: true, - labelText: "Search", - ), - autofocus: true, - ), + Image.asset("assets/images/no_scouters.png", width: 250), + const SizedBox(height: 8), + Text( + "No archived scouters found", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, ), - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: scouterOverviews != null, + ], + ), + ); + } else if (filteredScouters.isEmpty) { + body = PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/no_scouters.png", width: 250), + const SizedBox(height: 8), + Text( + "No results found", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, ), + const SizedBox(height: 2), ], - ))), - body: body, + ), + ); + } else { + body = ScrollablePageBody( + padding: EdgeInsets.zero, + children: filteredScouters + .map( + (scouterOverview) => ListTile( + leading: Monogram( + scouterOverview.scout.name.isNotEmpty + ? scouterOverview.scout.name + .substring(0, 1) + .toUpperCase() + : "", + ), + title: Text(scouterOverview.scout.name), + subtitle: Text(tournament == null + ? "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted" + : "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted, ${scouterOverview.missedMatches} missed"), + trailing: const Icon(Icons.arrow_right), + onTap: () { + Navigator.of(context).pushWidget( + ScouterDetailsPage( + scouterOverview: scouterOverview, + onChanged: () { + result.refetch(); + widget.onChanged?.call(); + }, + ), + ); + }, + ), + ) + .toList(), + ); + } + } + + if (result.hasError && scouterOverviews == null) { + body = FriendlyErrorView.result(result); + } + + return Scaffold( + appBar: AppBar( + title: const Text("Archived Scouters"), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(84), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + onChanged: (text) { + setState(() { + filterText = text; + }); + }, + decoration: const InputDecoration( + filled: true, + labelText: "Search", + ), + autofocus: true, + ), + ), + StaleRefreshIndicator.result(result), + ], + ))), + body: body, + ); + }, ); } } diff --git a/lib/pages/match_predictor.dart b/lib/pages/match_predictor.dart index 27589399..9ac322e3 100644 --- a/lib/pages/match_predictor.dart +++ b/lib/pages/match_predictor.dart @@ -10,6 +10,7 @@ import 'package:scouting_dashboard_app/reusable/models/robot_roles.dart'; import 'package:scouting_dashboard_app/reusable/navigation_drawer.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/value_tile.dart'; @@ -21,72 +22,8 @@ class MatchPredictorPage extends StatefulWidget { } class _MatchPredictorPageState extends State { - MatchPrediction? prediction; - String? error; - bool isRefreshing = false; - bool _notEnoughData = false; - late List _teams; - Future fetchData() async { - // Show stale data from cache immediately - final cached = lovatAPI.getCachedMatchPrediction( - _teams[0], - _teams[1], - _teams[2], - _teams[3], - _teams[4], - _teams[5], - ); - if (cached != null && prediction == null && error == null) { - setState(() { - prediction = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final result = await lovatAPI.getMatchPrediction( - _teams[0], - _teams[1], - _teams[2], - _teams[3], - _teams[4], - _teams[5], - ); - setState(() { - prediction = result; - error = null; - _notEnoughData = false; - }); - } on LovatAPIException catch (e) { - if (e.message == "Not enough data") { - if (prediction == null) { - setState(() { - _notEnoughData = true; - }); - } - } else if (prediction == null) { - setState(() { - error = e.message; - }); - } - } catch (e) { - if (prediction == null) { - setState(() { - error = "Error: $e"; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -100,12 +37,6 @@ class _MatchPredictorPageState extends State { int.parse(args['blue2']), int.parse(args['blue3']), ]; - if (prediction == null && - error == null && - !isRefreshing && - !_notEnoughData) { - fetchData(); - } } @override @@ -114,137 +45,134 @@ class _MatchPredictorPageState extends State { ? const GlobalNavigationDrawer() : null; - if (_notEnoughData && prediction == null) { - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - bottom: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: prediction != null, - ), - ), - body: notEnoughDataMessage(), - drawer: hasDrawer, - ); - } - - if (prediction == null) { - if (error != null) { - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - bottom: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: false, - ), - ), - body: PageBody(child: Text("Error: $error")), - drawer: hasDrawer, - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text("Match Predictor"), - ), - body: const PageBody(child: LinearProgressIndicator()), - drawer: hasDrawer, - ); - } + return StaleRefreshBuilder( + query: lovatAPI.matchPrediction(_teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5]), + builder: (context, result) { + final prediction = result.data; + final notEnoughData = + result.error?.contains("Not enough data") == true && prediction == null; - return DefaultTabController( - length: 2, - child: LayoutBuilder(builder: (context, constraints) { - if (constraints.maxHeight > constraints.maxWidth) { - // Portrait + if (notEnoughData) { return Scaffold( appBar: AppBar( title: const Text("Match Predictor"), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(89), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: WinningPrediction( - redWinning: prediction!.redWinning, - blueWinning: prediction!.blueWinning, - ), - ), - const TabBar(tabs: [ - Column( - children: [ - Text("Red"), - SizedBox(height: 7), - ], - ), - Column( - children: [ - Text("Blue"), - SizedBox(height: 7), - ], - ), - ]), - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: prediction != null, - ), - ], - ), - ), + bottom: StaleRefreshIndicator.result(result), ), - body: TabBarView(children: [ - ScrollablePageBody(children: [ - allianceTab(0, prediction!.redAlliance), - ]), - ScrollablePageBody(children: [ - allianceTab(1, prediction!.blueAlliance), - ]), - ]), + body: notEnoughDataMessage(), drawer: hasDrawer, ); - } else { - // Landscape + } + + if (prediction == null) { + if (result.error != null) { + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: StaleRefreshIndicator.result(result), + ), + body: PageBody(child: Text("Error: ${result.error}")), + drawer: hasDrawer, + ); + } + return Scaffold( appBar: AppBar( title: const Text("Match Predictor"), - bottom: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: prediction != null, - ), ), - body: SafeArea( - bottom: false, - child: ListView(children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: WinningPrediction( - redWinning: prediction!.redWinning, - blueWinning: prediction!.blueWinning, - ), - ), - Row(children: [ - Flexible( - flex: 1, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: allianceTab(0, prediction!.redAlliance), + body: const PageBody(child: LinearProgressIndicator()), + drawer: hasDrawer, + ); + } + + return DefaultTabController( + length: 2, + child: LayoutBuilder(builder: (context, constraints) { + if (constraints.maxHeight > constraints.maxWidth) { + // Portrait + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(89), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: WinningPrediction( + redWinning: prediction.redWinning, + blueWinning: prediction.blueWinning, + ), + ), + const TabBar(tabs: [ + Column( + children: [ + Text("Red"), + SizedBox(height: 7), + ], + ), + Column( + children: [ + Text("Blue"), + SizedBox(height: 7), + ], + ), + ]), + StaleRefreshIndicator.result(result), + ], ), ), - Flexible( - flex: 1, - child: Padding( + ), + body: TabBarView(children: [ + ScrollablePageBody(children: [ + allianceTab(0, prediction.redAlliance), + ]), + ScrollablePageBody(children: [ + allianceTab(1, prediction.blueAlliance), + ]), + ]), + drawer: hasDrawer, + ); + } else { + // Landscape + return Scaffold( + appBar: AppBar( + title: const Text("Match Predictor"), + bottom: StaleRefreshIndicator.result(result), + ), + body: SafeArea( + bottom: false, + child: ListView(children: [ + Padding( padding: const EdgeInsets.all(8.0), - child: allianceTab(1, prediction!.blueAlliance), + child: WinningPrediction( + redWinning: prediction.redWinning, + blueWinning: prediction.blueWinning, + ), ), - ), - ]), - ]), - ), - drawer: hasDrawer, - ); - } - }), + Row(children: [ + Flexible( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: allianceTab(0, prediction.redAlliance), + ), + ), + Flexible( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: allianceTab(1, prediction.blueAlliance), + ), + ), + ]), + ]), + ), + drawer: hasDrawer, + ); + } + }), + ); + }, ); } diff --git a/lib/pages/match_schedule.dart b/lib/pages/match_schedule.dart index bfd74b0f..00f34ee2 100644 --- a/lib/pages/match_schedule.dart +++ b/lib/pages/match_schedule.dart @@ -302,7 +302,7 @@ class _MatchSchedulePageState extends State { ), ), StaleRefreshIndicator( - isRefreshing: showProgressIndicator, + isFetching: showProgressIndicator, hasStaleData: matches != null, ), ], diff --git a/lib/pages/picklist/picklist.dart b/lib/pages/picklist/picklist.dart index 2e9a2c79..9d6295a9 100644 --- a/lib/pages/picklist/picklist.dart +++ b/lib/pages/picklist/picklist.dart @@ -12,6 +12,7 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/get_picklist_analysis.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:share_plus/share_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -152,138 +153,109 @@ class PicklistView extends StatefulWidget { } class _PicklistViewState extends State { - List? data; List? flags; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - final fetchedFlags = await getPicklistFlags(); - final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); - - final cached = - lovatAPI.getCachedPicklistAnalysis(flagPaths, widget.picklist.weights); - if (cached != null && data == null && error == null) { - setState(() { - flags = fetchedFlags; - data = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final result = await lovatAPI.getPicklistAnalysis( - flagPaths, widget.picklist.weights); - setState(() { - flags = fetchedFlags; - data = result; - error = null; - }); - } on LovatAPIException catch (e) { - if (data == null) { - setState(() => error = e.message); - } - } catch (_) { - if (data == null) { - setState(() => error = "Failed to load picklist"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } @override void initState() { super.initState(); - fetchData(); + _loadFlags(); + } + + Future _loadFlags() async { + final fetchedFlags = await getPicklistFlags(); + if (mounted) setState(() => flags = fetchedFlags); } @override Widget build(BuildContext context) { - if (error != null && data == null) { - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } - - if (data == null || flags == null) { + if (flags == null) { return SkeletonListView( itemBuilder: (context, index) => SkeletonListTile(), ); } - final result = data!; - - return Stack( - children: [ - ListView( - children: result - .map((teamData) => ListTile( - title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlagRow( - flags!, - Map.fromEntries( - teamData.flags - .map((e) => MapEntry(e.type, e.result)), - ), - teamData.teamNumber, - onEdit: fetchData, - ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - "/picklist_team_breakdown", - arguments: { + final flagPaths = flags!.map((e) => e.type.path).toList(); + + return StaleRefreshBuilder( + query: lovatAPI.picklistAnalysis(flagPaths, widget.picklist.weights), + builder: (context, result) { + final data = result.data; + if (result.hasError && data == null) { + return FriendlyErrorView.result(result); + } + + if (data == null) { + return SkeletonListView( + itemBuilder: (context, index) => SkeletonListTile(), + ); + } + + return Stack( + children: [ + ListView( + children: data + .map((teamData) => ListTile( + title: Text(teamData.teamNumber.toString()), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlagRow( + flags!, + Map.fromEntries( + teamData.flags + .map((e) => MapEntry(e.type, e.result)), + ), + teamData.teamNumber, + onEdit: result.refetch, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + "/picklist_team_breakdown", + arguments: { + 'team': teamData.teamNumber, + 'breakdown': teamData.zScoresWeighted, + 'unweighted': teamData.zScoresUnweighted, + 'picklistTitle': widget.picklist.meta.title, + }); + }, + icon: Icon( + Icons.balance, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: "View ${teamData.teamNumber}'s z-scores", + ), + IconButton( + onPressed: () { + Navigator.of(context) + .pushNamed("/team_lookup", arguments: { 'team': teamData.teamNumber, - 'breakdown': teamData.zScoresWeighted, - 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklist.meta.title, }); - }, - icon: Icon( - Icons.balance, - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: "View ${teamData.teamNumber}'s z-scores", + }, + icon: Icon( + Icons.arrow_right, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: + "Open team lookup for ${teamData.teamNumber}", + ), + ], ), - IconButton( - onPressed: () { - Navigator.of(context) - .pushNamed("/team_lookup", arguments: { - 'team': teamData.teamNumber, - }); - }, - icon: Icon( - Icons.arrow_right, - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: - "Open team lookup for ${teamData.teamNumber}", - ), - ], - ), - )) - .toList(), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - ), - ], + )) + .toList(), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + }, ); } } diff --git a/lib/pages/picklist/picklist_models.dart b/lib/pages/picklist/picklist_models.dart index 32b480a8..667be2ad 100644 --- a/lib/pages/picklist/picklist_models.dart +++ b/lib/pages/picklist/picklist_models.dart @@ -57,7 +57,7 @@ class ConfiguredPicklist { String? author; Future> fetchTeamRankings() async { - final analysis = await lovatAPI.getPicklistAnalysis([], weights); + final analysis = await lovatAPI.picklistAnalysis([], weights).queryFn(); if (analysis.isEmpty) { throw const LovatAPIException("Failed to fetch team rankings."); diff --git a/lib/pages/picklist/picklists.dart b/lib/pages/picklist/picklists.dart index d7e58e2e..e34c9f06 100644 --- a/lib/pages/picklist/picklists.dart +++ b/lib/pages/picklist/picklists.dart @@ -9,6 +9,7 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/shared/get_s import 'package:scouting_dashboard_app/reusable/navigation_drawer.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; class PicklistsPage extends StatefulWidget { @@ -235,197 +236,142 @@ class _MyPicklistsState extends State { } } -class SharedPicklists extends StatefulWidget { +class SharedPicklists extends StatelessWidget { const SharedPicklists({ super.key, }); @override - State createState() => _SharedPicklistsState(); -} - -class _SharedPicklistsState extends State { - List? picklists; - String? error; - bool isRefreshing = false; - - @override - void initState() { - super.initState(); - fetchData(); - } + Widget build(BuildContext context) { + return StaleRefreshBuilder( + query: lovatAPI.sharedPicklists(), + builder: (context, result) { + final picklists = result.data; + if (result.hasError && picklists == null) { + if (result.error == "Not on team") { + return const NotOnTeamMessage(); + } + return FriendlyErrorView.result(result); + } + + if (picklists == null) { + return const Column(children: [LinearProgressIndicator()]); + } + + return Stack( + children: [ + ScrollablePageBody( + padding: EdgeInsets.zero, + children: picklists + .map((picklist) => Column( + children: [ + Dismissible( + onUpdate: (details) { + if ((details.reached && + !details.previousReached) || + (!details.reached && + details.previousReached)) { + HapticFeedback.lightImpact(); + } + }, + key: GlobalKey(), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red[900], + child: const Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon(Icons.delete), + SizedBox(width: 30), + ], + ), + ), + ), + child: ListTile( + title: Text(picklist.title), + subtitle: picklist.author == null + ? null + : Text(picklist.author!), + trailing: Icon( + Icons.arrow_right, + color: + Theme.of(context).colorScheme.onSurface, + ), + onTap: () { + Navigator.of(context).pushNamed( + "/shared_picklist", + arguments: { + 'picklist': picklist, + }, + ); + }, + ), + onDismissed: (direction) async { + final scaffoldMessengerState = + ScaffoldMessenger.of(context); + final themeData = Theme.of(context); - Future fetchData() async { - final cached = lovatAPI.getCachedSharedPicklists(); - if (cached != null && picklists == null && error == null) { - setState(() { - picklists = cached; - }); - } + try { + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text("Deleting..."), + behavior: SnackBarBehavior.floating, + ), + ); - setState(() { - isRefreshing = true; - }); + await lovatAPI + .deleteSharedPicklist(picklist.id); - try { - final data = await lovatAPI.getSharedPicklists(); - setState(() { - picklists = data; - error = null; - }); - } on LovatAPIException catch (e) { - if (e.message == "Not on team" && picklists == null) { - setState(() { - error = "Not on team"; - }); - } else if (picklists == null) { - setState(() { - error = e.toString(); - }); - } - } catch (e) { - if (picklists == null) { - setState(() { - error = e.toString(); - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } + result.refetch(); - @override - Widget build(BuildContext context) { - if (error != null && picklists == null) { - if (error == "Not on team") { - return const NotOnTeamMessage(); - } - return FriendlyErrorView( - errorMessage: error!, - onRetry: fetchData, - ); - } + scaffoldMessengerState.hideCurrentSnackBar(); - if (picklists == null) { - return const Column(children: [LinearProgressIndicator()]); - } + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text( + 'Successfully deleted picklist "${picklist.title}"', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (error) { + scaffoldMessengerState.hideCurrentSnackBar(); - return Stack( - children: [ - ScrollablePageBody( - padding: EdgeInsets.zero, - children: picklists! - .map((picklist) => Column( - children: [ - Dismissible( - onUpdate: (details) { - if ((details.reached && !details.previousReached) || - (!details.reached && details.previousReached)) { - HapticFeedback.lightImpact(); - } - }, - key: GlobalKey(), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red[900], - child: const Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.delete), - SizedBox(width: 30), - ], - ), + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text( + error.toString(), + style: TextStyle( + color: themeData + .colorScheme.onErrorContainer), + ), + backgroundColor: + themeData.colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + ), + ); + } + }, ), - ), - child: ListTile( - title: Text(picklist.title), - subtitle: picklist.author == null - ? null - : Text(picklist.author!), - trailing: Icon( - Icons.arrow_right, - color: Theme.of(context).colorScheme.onSurface, + Divider( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + height: 0, ), - onTap: () { - Navigator.of(context).pushNamed( - "/shared_picklist", - arguments: { - 'picklist': picklist, - }, - ); - }, - ), - onDismissed: (direction) async { - final scaffoldMessengerState = - ScaffoldMessenger.of(context); - final themeData = Theme.of(context); - - try { - scaffoldMessengerState.showSnackBar( - const SnackBar( - content: Text("Deleting..."), - behavior: SnackBarBehavior.floating, - ), - ); - - await lovatAPI.deleteSharedPicklist(picklist.id); - - setState(() { - picklists! - .removeWhere((p) => p.id == picklist.id); - }); - - scaffoldMessengerState.hideCurrentSnackBar(); - - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text( - 'Successfully deleted picklist "${picklist.title}"', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } catch (error) { - scaffoldMessengerState.hideCurrentSnackBar(); - - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text( - error.toString(), - style: TextStyle( - color: themeData - .colorScheme.onErrorContainer), - ), - backgroundColor: - themeData.colorScheme.errorContainer, - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - ), - Divider( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - height: 0, - ), - ], - )) - .toList()), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: picklists != null, - ), - ), - ], + ], + )) + .toList()), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + }, ); } } @@ -464,218 +410,169 @@ class MutablePicklists extends StatefulWidget { } class _MutablePicklistsState extends State { - List? picklistsMeta; - String? error; - bool isRefreshing = false; bool loading = false; @override - void initState() { - super.initState(); - fetchData(); - } + Widget build(BuildContext context) { + return StaleRefreshBuilder( + query: lovatAPI.mutablePicklists(), + builder: (context, result) { + final picklistsMeta = result.data; + if (result.hasError && picklistsMeta == null) { + if (result.error == "Not on team") { + return const NotOnTeamMessage(); + } + return FriendlyErrorView.result(result); + } + + if (picklistsMeta == null) { + return const PageBody( + padding: EdgeInsets.zero, + child: Column( + children: [ + LinearProgressIndicator(), + ], + ), + ); + } - Future fetchData() async { - final cached = lovatAPI.getCachedMutablePicklists(); - if (cached != null && picklistsMeta == null && error == null) { - setState(() { - picklistsMeta = cached; - }); - } + return Stack( + children: [ + ScrollablePageBody( + padding: EdgeInsets.zero, + children: picklistsMeta + .map((picklistMeta) => Column( + children: [ + Dismissible( + onUpdate: (details) { + if ((details.reached && + !details.previousReached) || + (!details.reached && + details.previousReached)) { + HapticFeedback.lightImpact(); + } + }, + key: GlobalKey(), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red[900], + child: const Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon(Icons.delete), + SizedBox(width: 30), + ], + ), + ), + ), + child: ListTile( + title: Text(picklistMeta.name), + subtitle: picklistMeta.author == null + ? null + : Text(picklistMeta.author!), + trailing: Icon( + Icons.arrow_right, + color: + Theme.of(context).colorScheme.onSurface, + ), + onTap: () async { + setState(() { + loading = true; + }); + + final scaffoldMessengerState = + ScaffoldMessenger.of(context); + final themeData = Theme.of(context); + + try { + Navigator.of(context).pushNamed( + '/mutable_picklist', + arguments: { + 'picklist': + await picklistMeta.getPicklist(), + 'callback': () => result.refetch(), + }); + } catch (error) { + scaffoldMessengerState.showSnackBar( + SnackBar( + content: Text("Error: $error", + style: TextStyle( + color: themeData + .colorScheme + .onErrorContainer)), + backgroundColor: + themeData.colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + ), + ); + } finally { + setState(() { + loading = false; + }); + } + }, + ), + onDismissed: (direction) async { + final scaffoldMessengerState = + ScaffoldMessenger.of(context); + final themeData = Theme.of(context); - setState(() { - isRefreshing = true; - }); + scaffoldMessengerState.showSnackBar(const SnackBar( + content: Text("Deleting..."), + behavior: SnackBarBehavior.floating, + )); - try { - final data = await lovatAPI.getMutablePicklists(); - setState(() { - picklistsMeta = data; - error = null; - }); - } on LovatAPIException catch (e) { - if (e.message == "Not on team" && picklistsMeta == null) { - setState(() { - error = "Not on team"; - }); - } else if (picklistsMeta == null) { - setState(() { - error = e.toString(); - }); - } - } catch (e) { - if (picklistsMeta == null) { - setState(() { - error = e.toString(); - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } + try { + await picklistMeta.delete(); - @override - Widget build(BuildContext context) { - if (error != null && picklistsMeta == null) { - if (error == "Not on team") { - return const NotOnTeamMessage(); - } - return FriendlyErrorView( - errorMessage: error!, - onRetry: fetchData, - ); - } + result.refetch(); - if (picklistsMeta == null) { - return const PageBody( - padding: EdgeInsets.zero, - child: Column( - children: [ - LinearProgressIndicator(), - ], - ), - ); - } + scaffoldMessengerState.hideCurrentSnackBar(); - return Stack( - children: [ - ScrollablePageBody( - padding: EdgeInsets.zero, - children: picklistsMeta! - .map((picklistMeta) => Column( - children: [ - Dismissible( - onUpdate: (details) { - if ((details.reached && !details.previousReached) || - (!details.reached && details.previousReached)) { - HapticFeedback.lightImpact(); - } - }, - key: GlobalKey(), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red[900], - child: const Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.delete), - SizedBox(width: 30), - ], - ), - ), - ), - child: ListTile( - title: Text(picklistMeta.name), - subtitle: picklistMeta.author == null - ? null - : Text(picklistMeta.author!), - trailing: Icon( - Icons.arrow_right, - color: Theme.of(context).colorScheme.onSurface, - ), - onTap: () async { - setState(() { - loading = true; - }); - - final scaffoldMessengerState = - ScaffoldMessenger.of(context); - final themeData = Theme.of(context); - - try { - Navigator.of(context).pushNamed( - '/mutable_picklist', - arguments: { - 'picklist': - await picklistMeta.getPicklist(), - 'callback': () => setState(() {}), - }); - } catch (error) { - scaffoldMessengerState.showSnackBar( - SnackBar( - content: Text("Error: $error", - style: TextStyle( - color: themeData - .colorScheme.onErrorContainer)), + scaffoldMessengerState + .showSnackBar(const SnackBar( + content: Text("Successfully deleted"), + behavior: SnackBarBehavior.floating, + )); + } catch (error) { + scaffoldMessengerState.hideCurrentSnackBar(); + + scaffoldMessengerState.showSnackBar(SnackBar( + content: Text( + "Error deleting: $error", + style: TextStyle( + color: + themeData.colorScheme.onErrorContainer, + ), + ), + behavior: SnackBarBehavior.floating, backgroundColor: themeData.colorScheme.errorContainer, - behavior: SnackBarBehavior.floating, - ), - ); - } finally { - setState(() { - loading = false; - }); - } - }, - ), - onDismissed: (direction) async { - final scaffoldMessengerState = - ScaffoldMessenger.of(context); - final themeData = Theme.of(context); - - scaffoldMessengerState.showSnackBar(const SnackBar( - content: Text("Deleting..."), - behavior: SnackBarBehavior.floating, - )); - - try { - await picklistMeta.delete(); - - setState(() { - picklistsMeta!.removeWhere( - (p) => p.uuid == picklistMeta.uuid); - }); - - scaffoldMessengerState.hideCurrentSnackBar(); - - scaffoldMessengerState - .showSnackBar(const SnackBar( - content: Text("Successfully deleted"), - behavior: SnackBarBehavior.floating, - )); - } catch (error) { - scaffoldMessengerState.hideCurrentSnackBar(); - - scaffoldMessengerState.showSnackBar(SnackBar( - content: Text( - "Error deleting: $error", - style: TextStyle( - color: - themeData.colorScheme.onErrorContainer, - ), - ), - behavior: SnackBarBehavior.floating, - backgroundColor: - themeData.colorScheme.errorContainer, - )); + )); - return; - } - }, - ), - Divider( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - height: 0, - ), - ], - )) - .toList()), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: picklistsMeta != null, - ), - ), - ], + return; + } + }, + ), + Divider( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + height: 0, + ), + ], + )) + .toList()), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + }, ); } } diff --git a/lib/pages/picklist/shared_picklist.dart b/lib/pages/picklist/shared_picklist.dart index 9d38dc86..4793d7b9 100644 --- a/lib/pages/picklist/shared_picklist.dart +++ b/lib/pages/picklist/shared_picklist.dart @@ -5,8 +5,8 @@ import 'package:scouting_dashboard_app/reusable/flag_models.dart'; import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/get_picklist_analysis.dart'; -import 'package:scouting_dashboard_app/reusable/lovat_api/picklists/shared/get_shared_picklist_by_id.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; @@ -122,143 +122,115 @@ class SharedPicklistView extends StatefulWidget { } class _SharedPicklistViewState extends State { - List? data; List? flags; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - final fetchedFlags = await getPicklistFlags(); - final flagPaths = fetchedFlags.map((e) => e.type.path).toList(); - - final cachedPicklist = - lovatAPI.getCachedSharedPicklistById(widget.picklistMeta.id); - if (cachedPicklist != null && data == null && error == null) { - final cachedAnalysis = - lovatAPI.getCachedPicklistAnalysis(flagPaths, cachedPicklist.weights); - if (cachedAnalysis != null) { - setState(() { - flags = fetchedFlags; - data = cachedAnalysis; - }); - } - } - - setState(() { - isRefreshing = true; - }); - - try { - final picklist = await widget.picklistMeta.getPicklist(); - final result = - await lovatAPI.getPicklistAnalysis(flagPaths, picklist.weights); - setState(() { - flags = fetchedFlags; - data = result; - error = null; - }); - } on LovatAPIException catch (e) { - if (data == null) { - setState(() => error = e.message); - } - } catch (_) { - if (data == null) { - setState(() => error = "Failed to load picklist"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } @override void initState() { super.initState(); - fetchData(); + _loadFlags(); + } + + Future _loadFlags() async { + final fetchedFlags = await getPicklistFlags(); + if (mounted) setState(() => flags = fetchedFlags); } @override Widget build(BuildContext context) { - if (error != null && data == null) { - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } - - if (data == null || flags == null) { + if (flags == null) { return SkeletonListView( itemBuilder: (context, index) => SkeletonListTile(), ); } - final result = data!; + final flagPaths = flags!.map((e) => e.type.path).toList(); - return Stack( - children: [ - ListView( - children: result - .map((teamData) => ListTile( - title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FlagRow( - flags!, - Map.fromEntries( - teamData.flags - .map((e) => MapEntry(e.type, e.result)), - ), - teamData.teamNumber, - onEdit: fetchData, - ), - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - "/picklist_team_breakdown", - arguments: { + return StaleRefreshBuilder>( + query: CachedQuery( + queryKey: ['sharedPicklistAnalysis', widget.picklistMeta.id], + queryFn: () async { + final picklist = await widget.picklistMeta.getPicklist(); + return lovatAPI.picklistAnalysis(flagPaths, picklist.weights).queryFn(); + }, + ), + builder: (context, result) { + final data = result.data; + if (result.hasError && data == null) { + return FriendlyErrorView.result(result); + } + + if (data == null) { + return SkeletonListView( + itemBuilder: (context, index) => SkeletonListTile(), + ); + } + + return Stack( + children: [ + ListView( + children: data + .map((teamData) => ListTile( + title: Text(teamData.teamNumber.toString()), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlagRow( + flags!, + Map.fromEntries( + teamData.flags + .map((e) => MapEntry(e.type, e.result)), + ), + teamData.teamNumber, + onEdit: result.refetch, + ), + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + "/picklist_team_breakdown", + arguments: { + 'team': teamData.teamNumber, + 'breakdown': teamData.zScoresWeighted, + 'unweighted': teamData.zScoresUnweighted, + 'picklistTitle': widget.picklistMeta.title, + }); + }, + icon: Icon( + Icons.balance, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: "View ${teamData.teamNumber}'s z-scores", + ), + IconButton( + onPressed: () { + Navigator.of(context) + .pushNamed("/team_lookup", arguments: { 'team': teamData.teamNumber, - 'breakdown': teamData.zScoresWeighted, - 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklistMeta.title, }); - }, - icon: Icon( - Icons.balance, - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: "View ${teamData.teamNumber}'s z-scores", - ), - IconButton( - onPressed: () { - Navigator.of(context) - .pushNamed("/team_lookup", arguments: { - 'team': teamData.teamNumber, - }); - }, - icon: Icon( - Icons.arrow_right, - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: - "Open team lookup for ${teamData.teamNumber}", + }, + icon: Icon( + Icons.arrow_right, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: + "Open team lookup for ${teamData.teamNumber}", + ), + ], ), - ], - ), - )) - .toList(), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - ), - ], + )) + .toList(), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + }, ); } } diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index 4f8327ab..cfb01d98 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -14,241 +14,171 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/scouter_schedule/updat import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; -class EditScoutSchedulePage extends StatefulWidget { +class EditScoutSchedulePage extends StatelessWidget { const EditScoutSchedulePage({super.key}); - @override - State createState() => _EditScoutSchedulePageState(); -} - -class _EditScoutSchedulePageState extends State { - ServerScoutSchedule? scoutSchedule; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - final tournament = Tournament.currentSync ?? await Tournament.getCurrent(); - - if (tournament != null) { - final cached = lovatAPI.getCachedScouterSchedule(tournament.key); - if (cached != null && scoutSchedule == null && error == null) { - setState(() { - scoutSchedule = cached; - }); - } - } - - setState(() { - isRefreshing = true; - }); - - try { - final data = await lovatAPI.getScouterSchedule(); - - setState(() { - scoutSchedule = data; - error = null; - }); - } catch (e) { - if (scoutSchedule == null) { - setState(() { - error = "Failed to load scout schedule"; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } - @override Widget build(BuildContext context) { - Widget body = SkeletonListView( - itemBuilder: (context, index) => SkeletonListTile(), - ); - - if (error != null && scoutSchedule == null) { - body = FriendlyErrorView( - errorMessage: error!, - retryLabel: "Reload", - onRetry: () async { - setState(() { - error = null; - }); + return FutureBuilder( + future: Tournament.getCurrent(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(title: const Text("Edit Scout Schedule")), + body: const Center(child: CircularProgressIndicator()), + ); + } - await fetchData(); - }, - ); - } + final tournament = snapshot.data; + if (tournament == null) { + return Scaffold( + appBar: AppBar(title: const Text("Edit Scout Schedule")), + body: const Center(child: Text("No tournament selected")), + ); + } + + return StaleRefreshBuilder( + query: lovatAPI.scouterSchedule(tournament.key), + builder: (context, result) { + final scoutSchedule = result.data; + Widget body = SkeletonListView( + itemBuilder: (context, index) => SkeletonListTile(), + ); - if (scoutSchedule != null) { - body = Stack( - children: [ - ListView.builder( - itemBuilder: (context, index) { - final shift = scoutSchedule!.shifts[index]; - return Dismissible( - key: Key(shift.id), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red[900], - child: const Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.delete), - SizedBox(width: 30), - ], + if (result.hasError && scoutSchedule == null) { + body = FriendlyErrorView.result( + result, + retryLabel: "Reload", + ); + } + + if (scoutSchedule != null) { + body = Stack( + children: [ + ListView.builder( + itemBuilder: (context, index) { + final shift = scoutSchedule.shifts[index]; + return Dismissible( + key: Key(shift.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red[900], + child: const Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon(Icons.delete), + SizedBox(width: 30), + ], + ), + ), ), - ), - ), - onUpdate: (details) { - if ((details.reached && !details.previousReached) || - (!details.reached && details.previousReached)) { - HapticFeedback.lightImpact(); - } - }, - onDismissed: (direction) async { - final originalShifts = - List.from(scoutSchedule!.shifts); - final removedShifts = - List.from(originalShifts); - removedShifts.removeWhere((s) => s.id == shift.id); - - setState(() { - error = null; - isRefreshing = true; - scoutSchedule!.shifts = removedShifts; - }); + onUpdate: (details) { + if ((details.reached && !details.previousReached) || + (!details.reached && details.previousReached)) { + HapticFeedback.lightImpact(); + } + }, + confirmDismiss: (direction) async { + try { + await lovatAPI.deleteScoutScheduleShift(shift); + result.refetch(); + return true; + } catch (e) { + if (!context.mounted) return false; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Failed to delete shift")), + ); + return false; + } + }, + child: ListTile( + title: Text("${shift.start} to ${shift.end}"), + subtitle: Text(shift.allScoutsList), + onTap: () { + Navigator.of(context).pushWidget( + ScoutShiftEditor( + initialShift: shift.copy(), + onSubmit: (shift) async { + if (shift is ServerScoutingShift) { + try { + await lovatAPI + .updateScouterScheduleShift(shift); + result.refetch(); + } on LovatAPIException catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text("Failed to update shift")), + ); + } + } else { + throw Exception("Invalid shift type"); + } + }, + ), + ); + }, + ), + ); + }, + itemCount: scoutSchedule.shifts.length, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + } + return Scaffold( + appBar: AppBar( + title: const Text("Edit Scout Schedule"), + ), + body: body, + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + Navigator.of(context).pushWidget( + ScoutShiftEditor( + onSubmit: (shift) async { try { - await lovatAPI.deleteScoutScheduleShift(shift); - - await fetchData(); - } catch (e) { - setState(() { - scoutSchedule!.shifts = originalShifts; - }); - if (!mounted) return; + await lovatAPI.createScoutScheduleShift(shift); + result.refetch(); + } on LovatAPIException catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (_) { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Failed to delete shift")), + const SnackBar(content: Text("Failed to create shift")), ); - } finally { - setState(() { - isRefreshing = false; - }); } }, - child: ListTile( - title: Text("${shift.start} to ${shift.end}"), - subtitle: Text(shift.allScoutsList), - onTap: () { - Navigator.of(context).pushWidget( - ScoutShiftEditor( - initialShift: shift.copy(), - onSubmit: (shift) async { - if (shift is ServerScoutingShift) { - try { - setState(() { - error = null; - isRefreshing = true; - }); - - await lovatAPI - .updateScouterScheduleShift(shift); - - await fetchData(); - } on LovatAPIException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.message)), - ); - } catch (_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Failed to update shift")), - ); - } finally { - setState(() { - isRefreshing = false; - }); - } - } else { - throw Exception("Invalid shift type"); - } - }, - ), - ); - }, - ), - ); - }, - itemCount: scoutSchedule!.shifts.length, - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: scoutSchedule != null, - ), + ), + ); + }, ), - ], - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text("Edit Scout Schedule"), - ), - body: body, - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add), - onPressed: () { - Navigator.of(context).pushWidget( - ScoutShiftEditor( - onSubmit: (shift) async { - try { - setState(() { - error = null; - isRefreshing = true; - }); - - await lovatAPI.createScoutScheduleShift(shift); - - await fetchData(); - } on LovatAPIException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.message)), - ); - } catch (_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Failed to create shift")), - ); - } finally { - setState(() { - isRefreshing = false; - }); - } - }, - ), - ); - }, - ), + ); + }, + ); + }, ); } } diff --git a/lib/pages/scouters.dart b/lib/pages/scouters.dart index c6b6733c..b1add0e0 100644 --- a/lib/pages/scouters.dart +++ b/lib/pages/scouters.dart @@ -13,6 +13,7 @@ import 'package:scouting_dashboard_app/reusable/navigation_drawer.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; @@ -24,212 +25,156 @@ class ScoutersPage extends StatefulWidget { } class _ScoutersPageState extends State { - List? scouterOverviews; - String? error; - Tournament? tournament; - bool isRefreshing = false; - String filterText = ''; - Future fetchData() async { - final t = Tournament.currentSync ?? await Tournament.getCurrent(); - - setState(() { - tournament = t; - }); - - // Show stale data from cache immediately - final cached = lovatAPI.getCachedScouterOverviews( - tournamentKey: t?.key, - archivedScouters: false, - ); - if (cached != null && scouterOverviews == null && error == null) { - setState(() { - scouterOverviews = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final data = await lovatAPI.getScouterOverviews(); - - setState(() { - scouterOverviews = data; - error = null; - }); - } on LovatAPIException catch (e) { - if (scouterOverviews == null) { - setState(() { - error = e.message; - }); - } - } catch (_) { - if (scouterOverviews == null) { - setState(() { - error = "Failed to load scouters"; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } @override Widget build(BuildContext context) { - if (scouterOverviews == null) { - fetchData(); - } - - Widget body = SkeletonListView( - itemBuilder: (context, index) => SkeletonListTile(), - ); - final List? filteredScouters; - if (scouterOverviews != null) { - filteredScouters = scouterOverviews! - .where((scout) => - scout.scout.name.toLowerCase().contains(filterText.toLowerCase())) - .toList(); - } else { - filteredScouters = []; - } - - if (scouterOverviews != null) { - if (scouterOverviews!.isEmpty) { - body = PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/images/no_scouters.png", width: 250), - const SizedBox(height: 8), - Text( - "No scouters found", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 2), - Text( - "Tap + to create one.", - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ) - ], - ), + return StaleRefreshBuilder( + query: lovatAPI.scouterOverviews(), + builder: (context, result) { + final scouterOverviews = result.data; + final tournament = Tournament.currentSync; + + Widget body = SkeletonListView( + itemBuilder: (context, index) => SkeletonListTile(), ); - } else if (filteredScouters.isEmpty) { - body = PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/images/no_scouters.png", width: 250), - const SizedBox(height: 8), - Text( - "No results found", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, + final List? filteredScouters; + if (scouterOverviews != null) { + filteredScouters = scouterOverviews + .where((scout) => scout.scout.name + .toLowerCase() + .contains(filterText.toLowerCase())) + .toList(); + } else { + filteredScouters = []; + } + + if (scouterOverviews != null) { + if (scouterOverviews.isEmpty) { + body = PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/no_scouters.png", width: 250), + const SizedBox(height: 8), + Text( + "No scouters found", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + "Tap + to create one.", + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ) + ], ), - const SizedBox(height: 2), - ], - ), - ); - } else { - body = ScrollablePageBody( - padding: EdgeInsets.zero, - children: filteredScouters - .map( - (scouterOverview) => ListTile( - leading: Monogram( - scouterOverview.scout.name.isNotEmpty - ? scouterOverview.scout.name - .substring(0, 1) - .toUpperCase() - : "", + ); + } else if (filteredScouters.isEmpty) { + body = PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/no_scouters.png", width: 250), + const SizedBox(height: 8), + Text( + "No results found", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, ), - title: Text(scouterOverview.scout.name), - subtitle: Text(tournament == null - ? "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted" - : "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted, ${scouterOverview.missedMatches} missed"), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.of(context).pushWidget( - ScouterDetailsPage( - scouterOverview: scouterOverview, - onChanged: () => fetchData(), + const SizedBox(height: 2), + ], + ), + ); + } else { + body = ScrollablePageBody( + padding: EdgeInsets.zero, + children: filteredScouters + .map( + (scouterOverview) => ListTile( + leading: Monogram( + scouterOverview.scout.name.isNotEmpty + ? scouterOverview.scout.name + .substring(0, 1) + .toUpperCase() + : "", ), - ); + title: Text(scouterOverview.scout.name), + subtitle: Text(tournament == null + ? "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted" + : "${scouterOverview.totalMatches} match${scouterOverview.totalMatches == 1 ? '' : 'es'} scouted, ${scouterOverview.missedMatches} missed"), + trailing: const Icon(Icons.arrow_right), + onTap: () { + Navigator.of(context).pushWidget( + ScouterDetailsPage( + scouterOverview: scouterOverview, + onChanged: () => result.refetch(), + ), + ); + }, + ), + ) + .toList(), + ); + } + } + + if (result.hasError && scouterOverviews == null) { + body = FriendlyErrorView.result(result); + } + + return Scaffold( + appBar: AppBar( + title: const Text("Scouters"), + actions: [ + IconButton( + onPressed: () { + Navigator.of(context).pushWidget(ArchivedScoutersPage( + onChanged: () => result.refetch(), + )); }, - ), - ) - .toList(), - ); - } - } - - if (error != null && scouterOverviews == null) { - body = FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } - - return Scaffold( - appBar: AppBar( - title: const Text("Scouters"), - actions: [ - IconButton( - onPressed: () { - Navigator.of(context).pushWidget(ArchivedScoutersPage( - onChanged: () => fetchData(), - )); - }, - icon: const Icon(Icons.access_time), - tooltip: "View archived scouters") - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(84), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - onChanged: (text) { - setState(() { - filterText = text; - }); - }, - decoration: const InputDecoration( - filled: true, - labelText: "Search", + icon: const Icon(Icons.access_time), + tooltip: "View archived scouters") + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(84), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + onChanged: (text) { + setState(() { + filterText = text; + }); + }, + decoration: const InputDecoration( + filled: true, + labelText: "Search", + ), + autofocus: false, + ), ), - autofocus: false, - ), - ), - StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: scouterOverviews != null, - ), - ], - )), - ), - drawer: const GlobalNavigationDrawer(), - floatingActionButton: FloatingActionButton( - onPressed: () { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => - AddScouterDialog(onAdd: (name) => fetchData()), - ); - }, - child: const Icon(Icons.add), - ), - body: body, + StaleRefreshIndicator.result(result), + ], + )), + ), + drawer: const GlobalNavigationDrawer(), + floatingActionButton: FloatingActionButton( + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + AddScouterDialog(onAdd: (name) => result.refetch()), + ); + }, + child: const Icon(Icons.add), + ), + body: body, + ); + }, ); } } diff --git a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart index 34459c82..db037414 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart @@ -6,167 +6,109 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; -class TeamLookupBreakdownsTab extends StatefulWidget { +class TeamLookupBreakdownsTab extends StatelessWidget { const TeamLookupBreakdownsTab({super.key, required this.team}); final int team; - @override - State createState() => - _TeamLookupBreakdownsTabState(); -} - -class _TeamLookupBreakdownsTabState extends State { - BreakdownMetrics? data; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - // Show stale data from cache immediately - final cached = lovatAPI.getCachedBreakdownMetricsByTeamNumber(widget.team); - if (cached != null && data == null && error == null) { - setState(() { - data = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final result = - await lovatAPI.getBreakdownMetricsByTeamNumber(widget.team); - setState(() { - data = result; - error = null; - }); - } on LovatAPIException catch (e) { - if (data == null) { - setState(() => error = e.message); - } - } catch (_) { - if (data == null) { - setState(() => error = "Failed to load breakdowns"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } - - @override - void didUpdateWidget(TeamLookupBreakdownsTab oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.team != widget.team) { - setState(() { - data = null; - error = null; - }); - fetchData(); - } - } - @override Widget build(BuildContext context) { - if (data != null) { - return Stack( - children: [ - ScrollablePageBody( - children: breakdowns - .map( - (BreakdownData breakdownData) => Breakdown( - dataIdentity: breakdownData, - data: data!, - team: widget.team, - ), - ) - .toList(), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - ), - ], - ); - } - - if (error != null) { - if (error!.contains("NO_DATA_FOR_TEAM")) { - return PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + return StaleRefreshBuilder( + query: lovatAPI.breakdownMetrics(team), + builder: (context, result) { + final data = result.data; + final error = result.error; + if (data != null) { + return Stack( children: [ - Image.asset("assets/images/no_scouters.png", width: 250), - const SizedBox(height: 8), - Text( - "No data found", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, + ScrollablePageBody( + children: breakdowns + .map( + (BreakdownData breakdownData) => Breakdown( + dataIdentity: breakdownData, + data: data, + team: team, + ), + ) + .toList(), ), - const SizedBox(height: 2), - Text( - "Try using data from more teams or tournaments.", - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), ), ], - ), - ); - } - if (error!.contains("TEAM_DOES_NOT_EXIST")) { - return PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/images/awaiting_verification.png", - width: 250), - const SizedBox(height: 8), - Text( - "Team does not exist", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, + ); + } + + if (error != null) { + if (error.contains("NO_DATA_FOR_TEAM")) { + return PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/no_scouters.png", width: 250), + const SizedBox(height: 8), + Text( + "No data found", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + "Try using data from more teams or tournaments.", + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], ), - ], - ), - ); - } - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } + ); + } + if (error.contains("TEAM_DOES_NOT_EXIST")) { + return PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/awaiting_verification.png", + width: 250), + const SizedBox(height: 8), + Text( + "Team does not exist", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + return FriendlyErrorView.result(result); + } - return PageBody( - bottom: false, - padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - child: SkeletonListView( - itemCount: breakdowns.length, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: SizedBox( - height: 118, - child: SkeletonAvatar( - style: SkeletonAvatarStyle( - borderRadius: BorderRadius.circular(10), + return PageBody( + bottom: false, + padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + child: SkeletonListView( + itemCount: breakdowns.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: SizedBox( + height: 118, + child: SkeletonAvatar( + style: SkeletonAvatarStyle( + borderRadius: BorderRadius.circular(10), + ), + ), ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/pages/team_lookup/tabs/team_lookup_categories.dart b/lib/pages/team_lookup/tabs/team_lookup_categories.dart index da69bdce..0ea3b05c 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_categories.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_categories.dart @@ -4,206 +4,150 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_category_metrics.dart'; import 'package:scouting_dashboard_app/reusable/page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; -class TeamLookupCategoriesTab extends StatefulWidget { +class TeamLookupCategoriesTab extends StatelessWidget { const TeamLookupCategoriesTab({super.key, required this.team}); final int team; - @override - State createState() => - _TeamLookupCategoriesTabState(); -} - -class _TeamLookupCategoriesTabState extends State { - CategoryMetrics? data; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - // Show stale data from cache immediately - final cached = lovatAPI.getCachedCategoryMetricsByTeamNumber(widget.team); - if (cached != null && data == null && error == null) { - setState(() { - data = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final result = await lovatAPI.getCategoryMetricsByTeamNumber(widget.team); - setState(() { - data = result; - error = null; - }); - } on LovatAPIException catch (e) { - if (data == null) { - setState(() => error = e.message); - } - } catch (e) { - debugPrint(e.toString()); - if (data == null) { - setState(() => error = "Failed to load metrics"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } - - @override - void didUpdateWidget(TeamLookupCategoriesTab oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.team != widget.team) { - setState(() { - data = null; - error = null; - }); - fetchData(); - } - } - @override Widget build(BuildContext context) { - if (data != null) { - return Stack( - children: [ - ScrollablePageBody( - children: [ - MetricCategoryList( - metricCategories: metricCategories - .map((category) => MetricCategory( - categoryName: category.localizedName, - metricTiles: category.metrics - .where((metric) => metric.hideOverview == false) - .map( - (metric) => MetricTile( - value: (() { - try { - return metric.valueVizualizationBuilder( - data!.valueForMetric(metric)); - } catch (_) { - return "--"; - } - })(), - label: metric.abbreviatedLocalizedName, - onTap: metric.hideDetails - ? null - : () { - Navigator.of(context).pushNamed( - "/team_lookup_details", - arguments: { - 'category': category, - 'metric': metric.path, - 'team': widget.team, - }); - }, - ), - ) - .toList(), - onTap: category.metrics - .where((metric) => !metric.hideDetails) - .isEmpty - ? null - : () { - Navigator.of(context).pushNamed( - "/team_lookup_details", - arguments: { - 'category': category, - 'team': widget.team, - }); - }, - )) - .toList(), - ), - ], - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, - ), - ), - ], - ); - } - - if (error != null) { - if (error!.contains("NO_DATA_FOR_TEAM")) { - return PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + return StaleRefreshBuilder( + query: lovatAPI.categoryMetrics(team), + builder: (context, result) { + final data = result.data; + final error = result.error; + if (data != null) { + return Stack( children: [ - Image.asset("assets/images/no_scouters.png", width: 250), - const SizedBox(height: 8), - Text( - "No data found", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 2), - Text( - "Try using data from more teams or tournaments.", - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, + ScrollablePageBody( + children: [ + MetricCategoryList( + metricCategories: metricCategories + .map((category) => MetricCategory( + categoryName: category.localizedName, + metricTiles: category.metrics + .where( + (metric) => metric.hideOverview == false) + .map( + (metric) => MetricTile( + value: (() { + try { + return metric + .valueVizualizationBuilder( + data.valueForMetric(metric)); + } catch (_) { + return "--"; + } + })(), + label: metric.abbreviatedLocalizedName, + onTap: metric.hideDetails + ? null + : () { + Navigator.of(context).pushNamed( + "/team_lookup_details", + arguments: { + 'category': category, + 'metric': metric.path, + 'team': team, + }); + }, + ), + ) + .toList(), + onTap: category.metrics + .where((metric) => !metric.hideDetails) + .isEmpty + ? null + : () { + Navigator.of(context).pushNamed( + "/team_lookup_details", + arguments: { + 'category': category, + 'team': team, + }); + }, + )) + .toList(), + ), + ], + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), ), ], - ), - ); - } - if (error!.contains("TEAM_DOES_NOT_EXIST")) { - return PageBody( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/images/awaiting_verification.png", - width: 250), - const SizedBox(height: 8), - Text( - "Team does not exist", - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, + ); + } + + if (error != null) { + if (error.contains("NO_DATA_FOR_TEAM")) { + return PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/no_scouters.png", width: 250), + const SizedBox(height: 8), + Text( + "No data found", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + "Try using data from more teams or tournaments.", + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], ), - ], - ), - ); - } - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } + ); + } + if (error.contains("TEAM_DOES_NOT_EXIST")) { + return PageBody( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/awaiting_verification.png", + width: 250), + const SizedBox(height: 8), + Text( + "Team does not exist", + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + return FriendlyErrorView.result(result); + } - return PageBody( - bottom: false, - padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), - child: SkeletonListView( - itemCount: metricCategories.length, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 15), - child: SizedBox( - height: 117, - child: SkeletonAvatar( - style: SkeletonAvatarStyle( - borderRadius: BorderRadius.circular(10), + return PageBody( + bottom: false, + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), + child: SkeletonListView( + itemCount: metricCategories.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 15), + child: SizedBox( + height: 117, + child: SkeletonAvatar( + style: SkeletonAvatarStyle( + borderRadius: BorderRadius.circular(10), + ), + ), ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/pages/team_lookup/tabs/team_lookup_notes.dart b/lib/pages/team_lookup/tabs/team_lookup_notes.dart index 20d0eca9..45456b35 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_notes.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_notes.dart @@ -7,150 +7,93 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_notes. import 'package:scouting_dashboard_app/reusable/page_body.dart'; import 'package:scouting_dashboard_app/reusable/push_widget_extension.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:skeletons_forked/skeletons_forked.dart'; -class TeamLookupNotesTab extends StatefulWidget { +class TeamLookupNotesTab extends StatelessWidget { const TeamLookupNotesTab({super.key, required this.team}); final int team; - @override - State createState() => _TeamLookupNotesTabState(); -} - -class _TeamLookupNotesTabState extends State { - List? notes; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - // Show stale data from cache immediately - final cached = lovatAPI.getCachedNotesByTeamNumber(widget.team); - if (cached != null && notes == null && error == null) { - setState(() { - notes = [ - ...cached.where((e) => e.type == NoteType.breakDescription), - ...cached.where((e) => e.type != NoteType.breakDescription), - ]; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final fetched = await lovatAPI.getNotesByTeamNumber(widget.team); - setState(() { - notes = [ - ...fetched.where((e) => e.type == NoteType.breakDescription), - ...fetched.where((e) => e.type != NoteType.breakDescription), - ]; - error = null; - }); - } on LovatAPIException catch (e) { - if (notes == null) { - setState(() => error = e.message); - } - } catch (_) { - if (notes == null) { - setState(() => error = "Failed to load notes"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } - - @override - void didUpdateWidget(TeamLookupNotesTab oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.team != widget.team) { - setState(() { - notes = null; - error = null; - }); - fetchData(); - } - } - @override Widget build(BuildContext context) { - if (notes != null) { - return Stack( - children: [ - notes!.isEmpty - ? SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 100), - Image.asset( - 'assets/images/no-notes-${Theme.of(context).brightness.name}.png', - width: 250, - ), - Text( - "No notes on ${widget.team}", - style: Theme.of(context).textTheme.headlineMedium, + return StaleRefreshBuilder( + query: lovatAPI.notes(team), + builder: (context, result) { + final data = result.data; + final error = result.error; + final refetch = result.refetch; + if (data != null) { + final sortedNotes = [ + ...data.where((e) => e.type == NoteType.breakDescription), + ...data.where((e) => e.type != NoteType.breakDescription), + ]; + return Stack( + children: [ + sortedNotes.isEmpty + ? SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 100), + Image.asset( + 'assets/images/no-notes-${Theme.of(context).brightness.name}.png', + width: 250, + ), + Text( + "No notes on $team", + style: Theme.of(context).textTheme.headlineMedium, + ), + ], ), - ], - ), - ) - : ScrollablePageBody( - children: [ - NotesList( - notes: notes! - .map( - (note) => NoteWidget( - note, - onEdit: fetchData, - ), - ) - .toList(), + ) + : ScrollablePageBody( + children: [ + NotesList( + notes: sortedNotes + .map( + (note) => NoteWidget( + note, + onEdit: refetch, + ), + ) + .toList(), + ), + ], ), - ], - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: notes != null, - ), - ), - ], - ); - } + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + } - if (error != null) { - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } + if (error != null) { + return FriendlyErrorView.result(result); + } - return PageBody( - bottom: false, - padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - child: SkeletonListView( - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(bottom: 20), - child: SkeletonAvatar( - style: SkeletonAvatarStyle( - borderRadius: BorderRadius.circular(10), - randomHeight: true, - minHeight: 74, - maxHeight: 160, + return PageBody( + bottom: false, + padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + child: SkeletonListView( + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 20), + child: SkeletonAvatar( + style: SkeletonAvatarStyle( + borderRadius: BorderRadius.circular(10), + randomHeight: true, + minHeight: 74, + maxHeight: 160, + ), + ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/pages/team_lookup/team_lookup_breakdown_details.dart b/lib/pages/team_lookup/team_lookup_breakdown_details.dart index c297a744..13a503a5 100644 --- a/lib/pages/team_lookup/team_lookup_breakdown_details.dart +++ b/lib/pages/team_lookup/team_lookup_breakdown_details.dart @@ -6,9 +6,10 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_breakdown_details.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; -class BreakdownDetailsPage extends StatefulWidget { +class BreakdownDetailsPage extends StatelessWidget { const BreakdownDetailsPage({ super.key, required this.team, @@ -18,120 +19,67 @@ class BreakdownDetailsPage extends StatefulWidget { final int team; final BreakdownData breakdownIdentity; - @override - State createState() => _BreakdownDetailsPageState(); -} - -class _BreakdownDetailsPageState extends State { - BreakdownDetailsResponse? response; - bool hasError = false; - bool isRefreshing = false; - - Future loadData() async { - final cached = lovatAPI.getCachedBreakdownDetails( - widget.team, - widget.breakdownIdentity.path, - ); - if (cached != null && response == null && !hasError) { - setState(() { - response = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final data = await lovatAPI.getBreakdownDetails( - widget.team, - widget.breakdownIdentity.path, - ); - - setState(() { - response = data; - hasError = false; - }); - } catch (_) { - if (response == null) { - setState(() { - hasError = true; - }); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - loadData(); - } - @override Widget build(BuildContext context) { - Widget body = const Column(children: [LinearProgressIndicator()]); - - if (hasError && response == null) { - body = FriendlyErrorView(onRetry: loadData); - } - - if (response != null) { - body = Stack( - children: [ - ScrollablePageBody( - children: response!.matchesWithSegments - .map((segment) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SectionTitle(segment.segmentName), - const SizedBox(height: 8), - ...segment.matches - .map((match) { - return Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text(match.matchIdentity - .getLocalizedDescription( - abbreviateName: true)), - Text(match.sourceDescription), - ], - ); - }) - .toList() - .withSpaceBetween(height: 7) - ], - ); - }) - .toList() - .withWidgetBetween(const Padding( - padding: EdgeInsets.only(top: 14), - child: Divider(height: 1), - ))), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: response != null, - ), - ), - ], - ); - } - return Scaffold( appBar: AppBar( - title: - Text("${widget.team} - ${widget.breakdownIdentity.localizedName}"), + title: Text("$team - ${breakdownIdentity.localizedName}"), + ), + body: StaleRefreshBuilder( + query: lovatAPI.breakdownDetails(team, breakdownIdentity.path), + builder: (context, result) { + final data = result.data; + if (result.hasError && !result.hasData) { + return FriendlyErrorView.result(result); + } + + if (data == null) { + return const Column(children: [LinearProgressIndicator()]); + } + + return Stack( + children: [ + ScrollablePageBody( + children: data.matchesWithSegments + .map((segment) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SectionTitle(segment.segmentName), + const SizedBox(height: 8), + ...segment.matches + .map((match) { + return Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(match.matchIdentity + .getLocalizedDescription( + abbreviateName: true)), + Text(match.sourceDescription), + ], + ); + }) + .toList() + .withSpaceBetween(height: 7) + ], + ); + }) + .toList() + .withWidgetBetween(const Padding( + padding: EdgeInsets.only(top: 14), + child: Divider(height: 1), + ))), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + }, ), - body: body, ); } } diff --git a/lib/pages/team_lookup/team_lookup_details.dart b/lib/pages/team_lookup/team_lookup_details.dart index 1ef8027b..30f6ab30 100644 --- a/lib/pages/team_lookup/team_lookup_details.dart +++ b/lib/pages/team_lookup/team_lookup_details.dart @@ -5,6 +5,7 @@ import 'package:scouting_dashboard_app/reusable/friendly_error_view.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/team_lookup/get_metric_details.dart'; import 'package:scouting_dashboard_app/reusable/scrollable_page_body.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_indicator.dart'; import 'package:scouting_dashboard_app/reusable/team_auto_paths.dart'; @@ -123,7 +124,7 @@ class _TeamLookupDetailsPageState extends State { } } -class AnalysisOverview extends StatefulWidget { +class AnalysisOverview extends StatelessWidget { const AnalysisOverview({ super.key, required this.teamNumber, @@ -133,184 +134,129 @@ class AnalysisOverview extends StatefulWidget { final int teamNumber; final CategoryMetric metric; - @override - State createState() => _AnalysisOverviewState(); -} - -class _AnalysisOverviewState extends State { - MetricDetails? data; - String? error; - bool isRefreshing = false; - - Future fetchData() async { - final cached = - lovatAPI.getCachedMetricDetails(widget.teamNumber, widget.metric.path); - if (cached != null && data == null && error == null) { - setState(() { - data = cached; - }); - } - - setState(() { - isRefreshing = true; - }); - - try { - final result = await lovatAPI.getMetricDetails( - widget.teamNumber, widget.metric.path); - setState(() { - data = result; - error = null; - }); - } on LovatAPIException catch (e) { - if (data == null) { - setState(() => error = e.message); - } - } catch (_) { - if (data == null) { - setState(() => error = "Failed to load metric details"); - } - } finally { - setState(() { - isRefreshing = false; - }); - } - } - - @override - void initState() { - super.initState(); - fetchData(); - } - - @override - void didUpdateWidget(AnalysisOverview oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.teamNumber != widget.teamNumber || - oldWidget.metric.path != widget.metric.path) { - fetchData(); - } - } - @override Widget build(BuildContext context) { - if (error != null && data == null) { - return FriendlyErrorView(errorMessage: error, onRetry: fetchData); - } + return StaleRefreshBuilder( + query: lovatAPI.metricDetails(teamNumber, metric.path), + builder: (context, result) { + final data = result.data; + final error = result.error; + if (error != null && data == null) { + return FriendlyErrorView.result(result); + } - if (data == null) { - return const Center(child: CircularProgressIndicator()); - } + if (data == null) { + return const Center(child: CircularProgressIndicator()); + } - final d = data!; + final d = data; - return Stack( - children: [ - SingleChildScrollView( - child: Column( - children: [ - Row(children: [ - if (d.hasResult) - valueBox( - context, - Text( - widget.metric.valueVizualizationBuilder(d.result), - style: Theme.of(context).textTheme.headlineSmall!.merge( - TextStyle( - color: - Theme.of(context).colorScheme.onPrimaryContainer), - ), - ), - "This team", - false, - ), - if (d.hasResult) const SizedBox(width: 10), - if (d.hasAll) - Flexible( - flex: 5, - fit: FlexFit.tight, - child: valueBox( - context, - Text( - widget.metric.valueVizualizationBuilder(d.all), - style: Theme.of(context).textTheme.headlineSmall!.merge( - TextStyle( - color: Theme.of(context).colorScheme.onPrimary), - ), + return Stack( + children: [ + SingleChildScrollView( + child: Column( + children: [ + Row(children: [ + if (d.hasResult) + valueBox( + context, + Text( + metric.valueVizualizationBuilder(d.result), + style: Theme.of(context).textTheme.headlineSmall!.merge( + TextStyle( + color: + Theme.of(context).colorScheme.onPrimaryContainer), + ), + ), + "This team", + false, ), - "All teams", - true, - ), - ), - if (d.hasAll) const SizedBox(width: 10), - if (d.hasDifference) - Flexible( - flex: 6, - fit: FlexFit.tight, - child: valueBox( - context, - d.difference == null - ? Text( - "--", - style: Theme.of(context).textTheme.headlineSmall!.merge( - TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer), - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - d.difference!.isNegative - ? Icons.arrow_drop_down - : Icons.arrow_drop_up, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, + if (d.hasResult) const SizedBox(width: 10), + if (d.hasAll) + Flexible( + flex: 5, + fit: FlexFit.tight, + child: valueBox( + context, + Text( + metric.valueVizualizationBuilder(d.all), + style: Theme.of(context).textTheme.headlineSmall!.merge( + TextStyle( + color: Theme.of(context).colorScheme.onPrimary), ), - Text( - widget.metric - .valueVizualizationBuilder(d.difference!.abs()), - style: Theme.of(context) - .textTheme - .headlineSmall! - .merge( + ), + "All teams", + true, + ), + ), + if (d.hasAll) const SizedBox(width: 10), + if (d.hasDifference) + Flexible( + flex: 6, + fit: FlexFit.tight, + child: valueBox( + context, + d.difference == null + ? Text( + "--", + style: Theme.of(context).textTheme.headlineSmall!.merge( TextStyle( color: Theme.of(context) .colorScheme .onPrimaryContainer), ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + d.difference!.isNegative + ? Icons.arrow_drop_down + : Icons.arrow_drop_up, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + Text( + metric + .valueVizualizationBuilder(d.difference!.abs()), + style: Theme.of(context) + .textTheme + .headlineSmall! + .merge( + TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer), + ), + ), + ], ), - ], - ), - "Difference", - false, - ), - ), - ]), - if (d.array.isNotEmpty) ...[ - const SizedBox(height: 10), - sparkline(context, d, widget.metric.max), - ], - if (d.paths.isNotEmpty) - TeamAutoPaths( - autoPaths: d.paths, - ), + "Difference", + false, + ), + ), + ]), + if (d.array.isNotEmpty) ...[ + const SizedBox(height: 10), + sparkline(context, d, metric.max), ], - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator( - isRefreshing: isRefreshing, - hasStaleData: data != null, + if (d.paths.isNotEmpty) + TeamAutoPaths( + autoPaths: d.paths, + ), + ], ), - ), - ], + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + }, ); } @@ -353,7 +299,7 @@ class _AnalysisOverviewState extends State { topTitles: const AxisTitles(), leftTitles: AxisTitles( axisNameWidget: Text( - widget.metric.abbreviatedNameWithUnits, + metric.abbreviatedNameWithUnits, ), sideTitles: SideTitles( showTitles: true, diff --git a/lib/reusable/friendly_error_view.dart b/lib/reusable/friendly_error_view.dart index e4479321..85781813 100644 --- a/lib/reusable/friendly_error_view.dart +++ b/lib/reusable/friendly_error_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class FriendlyErrorView extends StatelessWidget { const FriendlyErrorView({ @@ -8,6 +9,13 @@ class FriendlyErrorView extends StatelessWidget { this.retryLabel = "Retry", }); + FriendlyErrorView.result( + QueryResult result, { + super.key, + this.retryLabel = "Retry", + }) : errorMessage = result.error, + onRetry = result.refetch; + final String? errorMessage; final dynamic Function()? onRetry; final String retryLabel; diff --git a/lib/reusable/lovat_api/get_alliance_analysis.dart b/lib/reusable/lovat_api/get_alliance_analysis.dart index 45c90272..6a1c3c2a 100644 --- a/lib/reusable/lovat_api/get_alliance_analysis.dart +++ b/lib/reusable/lovat_api/get_alliance_analysis.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/robot_roles.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/team_auto_paths.dart'; class AllianceTeam { @@ -67,35 +68,33 @@ class AllianceAnalysis { } extension GetAllianceAnalysis on LovatAPI { - AllianceAnalysis? getCachedAllianceAnalysis(List teams) { - return getCachedData( - '/v1/analysis/alliance', - query: { - 'teamOne': teams[0].toString(), - 'teamTwo': teams[1].toString(), - 'teamThree': teams[2].toString(), - }, - parser: (json) => AllianceAnalysis.fromJson(json as Map), - ); - } + CachedQuery allianceAnalysis(List teams) { + const path = '/v1/analysis/alliance'; + final query = { + 'teamOne': teams[0].toString(), + 'teamTwo': teams[1].toString(), + 'teamThree': teams[2].toString(), + }; + return CachedQuery( + queryKey: ['allianceAnalysis', teams[0], teams[1], teams[2]], + queryFn: () async { + final response = await get(path, query: query); - Future getAllianceAnalysis(List teams) async { - final response = await get( - '/v1/analysis/alliance', - query: { - 'teamOne': teams[0].toString(), - 'teamTwo': teams[1].toString(), - 'teamThree': teams[2].toString(), - }, - ); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get alliance analysis'); - } + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get alliance analysis'); + } - return AllianceAnalysis.fromJson( - jsonDecode(response!.body) as Map, + return AllianceAnalysis.fromJson( + jsonDecode(response!.body) as Map, + ); + }, + cacheReader: () => getCachedData( + path, + query: query, + parser: (json) => + AllianceAnalysis.fromJson(json as Map), + ), ); } } diff --git a/lib/reusable/lovat_api/get_match_prediction.dart b/lib/reusable/lovat_api/get_match_prediction.dart index f83a4d1b..febd9ddb 100644 --- a/lib/reusable/lovat_api/get_match_prediction.dart +++ b/lib/reusable/lovat_api/get_match_prediction.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/get_alliance_analysis.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class MatchPrediction { const MatchPrediction({ @@ -32,7 +33,7 @@ class MatchPrediction { } extension GetMatchPrediction on LovatAPI { - MatchPrediction? getCachedMatchPrediction( + CachedQuery matchPrediction( int red1, int red2, int red3, @@ -40,51 +41,39 @@ extension GetMatchPrediction on LovatAPI { int blue2, int blue3, ) { - return getCachedData( - '/v1/analysis/matchprediction', - query: { - 'red1': red1.toString(), - 'red2': red2.toString(), - 'red3': red3.toString(), - 'blue1': blue1.toString(), - 'blue2': blue2.toString(), - 'blue3': blue3.toString(), - }, - parser: (json) => MatchPrediction.fromJson(json as Map), - ); - } - - Future getMatchPrediction( - int red1, - int red2, - int red3, - int blue1, - int blue2, - int blue3, - ) async { - final response = await get( - '/v1/analysis/matchprediction', - query: { - 'red1': red1.toString(), - 'red2': red2.toString(), - 'red3': red3.toString(), - 'blue1': blue1.toString(), - 'blue2': blue2.toString(), - 'blue3': blue3.toString(), - }, - ); + const path = '/v1/analysis/matchprediction'; + final query = { + 'red1': red1.toString(), + 'red2': red2.toString(), + 'red3': red3.toString(), + 'blue1': blue1.toString(), + 'blue2': blue2.toString(), + 'blue3': blue3.toString(), + }; + return CachedQuery( + queryKey: ['matchPrediction', red1, red2, red3, blue1, blue2, blue3], + queryFn: () async { + final response = await get(path, query: query); - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get match prediction'); - } + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get match prediction'); + } - if (response?.body == 'not enough data') { - throw const LovatAPIException('Not enough data'); - } + if (response?.body == 'not enough data') { + throw const LovatAPIException('Not enough data'); + } - return MatchPrediction.fromJson( - jsonDecode(response!.body) as Map, + return MatchPrediction.fromJson( + jsonDecode(response!.body) as Map, + ); + }, + cacheReader: () => getCachedData( + path, + query: query, + parser: (json) => + MatchPrediction.fromJson(json as Map), + ), ); } } diff --git a/lib/reusable/lovat_api/get_scouter_overviews.dart b/lib/reusable/lovat_api/get_scouter_overviews.dart index 32f004e0..94e183ff 100644 --- a/lib/reusable/lovat_api/get_scouter_overviews.dart +++ b/lib/reusable/lovat_api/get_scouter_overviews.dart @@ -3,52 +3,56 @@ import 'dart:convert'; import 'package:scouting_dashboard_app/datatypes.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; extension GetScouterOverviews on LovatAPI { - List? getCachedScouterOverviews({ - String? tournamentKey, - bool archivedScouters = false, - }) { - return getCachedData( - '/v1/manager/scouterspage', - query: { - if (tournamentKey != null) 'tournamentKey': tournamentKey, - 'archived': archivedScouters.toString(), - }, - parser: (json) => (json as List) - .map((e) => ScouterOverview.fromJson(e, archived: archivedScouters)) - .toList(), - ); - } + CachedQuery> scouterOverviews( + {bool archivedScouters = false}) { + const path = '/v1/manager/scouterspage'; + return CachedQuery( + queryKey: ['scouterOverviews', archivedScouters], + queryFn: () async { + final tournament = await Tournament.getCurrent(); - /// archivedScouters - true: show archived scouters only, false: show unarchived scouters only - Future> getScouterOverviews( - {bool archivedScouters = false}) async { - final tournament = await Tournament.getCurrent(); + final response = await get( + path, + query: { + if (tournament != null) 'tournamentKey': tournament.key, + 'archived': archivedScouters.toString(), + }, + ); - final response = await lovatAPI.get( - '/v1/manager/scouterspage', - query: { - if (tournament != null) 'tournamentKey': tournament.key, - 'archived': archivedScouters.toString(), - }, - ); + if (response?.statusCode != 200) { + try { + throw LovatAPIException(jsonDecode(response!.body)['displayError']); + } on LovatAPIException { + rethrow; + } catch (_) { + throw Exception('Failed to get scouter overviews'); + } + } - if (response?.statusCode != 200) { - try { - throw LovatAPIException(jsonDecode(response!.body)['displayError']); - } on LovatAPIException { - rethrow; - } catch (_) { - throw Exception('Failed to get scouter overviews'); - } - } + final json = jsonDecode(response!.body) as List; - final json = jsonDecode(response!.body) as List; - - return json - .map((e) => ScouterOverview.fromJson(e, archived: archivedScouters)) - .toList(); + return json + .map((e) => ScouterOverview.fromJson(e, archived: archivedScouters)) + .toList(); + }, + cacheReader: () { + final tournamentKey = Tournament.currentSync?.key; + return getCachedData( + path, + query: { + if (tournamentKey != null) 'tournamentKey': tournamentKey, + 'archived': archivedScouters.toString(), + }, + parser: (json) => (json as List) + .map((e) => + ScouterOverview.fromJson(e, archived: archivedScouters)) + .toList(), + ); + }, + ); } } diff --git a/lib/reusable/lovat_api/lovat_api.dart b/lib/reusable/lovat_api/lovat_api.dart index 436eb198..adc871ca 100644 --- a/lib/reusable/lovat_api/lovat_api.dart +++ b/lib/reusable/lovat_api/lovat_api.dart @@ -189,6 +189,10 @@ class LovatAPI { return Uri.parse(baseUrl + path).replace(queryParameters: query).toString(); } + String cacheKeyFor(String path, {Map? query}) { + return _cacheKey(path, query: query); + } + Future post( String path, { Map? body, diff --git a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart index a63d4869..219b39e6 100644 --- a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart +++ b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart @@ -6,6 +6,7 @@ import 'package:scouting_dashboard_app/datatypes.dart'; import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/flag_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class PicklistBreakdownEntry { const PicklistBreakdownEntry({ @@ -58,58 +59,60 @@ class PicklistAnalysisTeam { } extension GetPicklistAnalysis on LovatAPI { - List? getCachedPicklistAnalysis( + CachedQuery> picklistAnalysis( List flags, List weights, ) { - final tournament = Tournament.currentSync; - return getCachedData( - '/v1/analysis/picklist', - query: { - if (tournament != null) 'tournamentKey': tournament.key, - 'flags': jsonEncode(flags), - ...Map.fromEntries( - weights.map((e) => MapEntry(e.path, e.value.toString())).toList(), - ), - }, - parser: (json) { - final map = json as Map; - return (map['teams'] as List) + const path = '/v1/analysis/picklist'; + return CachedQuery( + queryKey: ['picklistAnalysis', flags, weights], + queryFn: () async { + final tournament = await Tournament.getCurrent(); + + final response = await get( + path, + query: { + if (tournament != null) 'tournamentKey': tournament.key, + 'flags': jsonEncode(flags), + ...Map.fromEntries( + weights.map((e) => MapEntry(e.path, e.value.toString())).toList(), + ), + }, + ); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get picklist analysis'); + } + + final json = jsonDecode(response!.body) as Map; + + return (json['teams'] as List) .map((team) => PicklistAnalysisTeam.fromJson(team as Map)) .toList(); }, - ); - } - - Future> getPicklistAnalysis( - List flags, - List weights, - ) async { - final tournament = await Tournament.getCurrent(); - - final response = await get( - '/v1/analysis/picklist', - query: { - if (tournament != null) 'tournamentKey': tournament.key, - 'flags': jsonEncode(flags), - ...Map.fromEntries( - weights.map((e) => MapEntry(e.path, e.value.toString())).toList(), - ), + cacheReader: () { + final tournament = Tournament.currentSync; + return getCachedData( + path, + query: { + if (tournament != null) 'tournamentKey': tournament.key, + 'flags': jsonEncode(flags), + ...Map.fromEntries( + weights.map((e) => MapEntry(e.path, e.value.toString())).toList(), + ), + }, + parser: (json) { + final map = json as Map; + return (map['teams'] as List) + .map((team) => + PicklistAnalysisTeam.fromJson(team as Map)) + .toList(); + }, + ); }, ); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get picklist analysis'); - } - - final json = jsonDecode(response!.body) as Map; - - return (json['teams'] as List) - .map((team) => - PicklistAnalysisTeam.fromJson(team as Map)) - .toList(); } /// Fetches the full picklist analysis and returns it as a CSV string. @@ -120,7 +123,7 @@ extension GetPicklistAnalysis on LovatAPI { required List weights, }) async { final flagPaths = flags.map((e) => e.type.path).toList(); - final teams = await getPicklistAnalysis(flagPaths, weights); + final teams = await picklistAnalysis(flagPaths, weights).queryFn(); final List columns = [ "teamNumber", diff --git a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart index 34368b28..fac82e4c 100644 --- a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart +++ b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart @@ -3,33 +3,37 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; extension GetMutablePicklists on LovatAPI { - List? getCachedMutablePicklists() { - return getCachedData( - '/v1/manager/mutablepicklists', - parser: (json) => (json as List) - .map((e) => MutablePicklistMeta.fromJson(e)) - .toList(), + CachedQuery> mutablePicklists() { + const path = '/v1/manager/mutablepicklists'; + return CachedQuery( + queryKey: const ['mutablePicklists'], + queryFn: () async { + final response = await get(path); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + + if (response?.body == + 'Not authortized to get mutable picklists because your not on a team') { + throw const LovatAPIException('Not on team'); + } + + throw Exception('Failed to get mutable picklists'); + } + + final json = jsonDecode(response!.body) as List; + + return json.map((e) => MutablePicklistMeta.fromJson(e)).toList(); + }, + cacheReader: () => getCachedData( + path, + parser: (json) => (json as List) + .map((e) => MutablePicklistMeta.fromJson(e)) + .toList(), + ), ); } - - Future> getMutablePicklists() async { - final response = await get('/v1/manager/mutablepicklists'); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - - if (response?.body == - 'Not authortized to get mutable picklists because your not on a team') { - throw const LovatAPIException('Not on team'); - } - - throw Exception('Failed to get mutable picklists'); - } - - final json = jsonDecode(response!.body) as List; - - return json.map((e) => MutablePicklistMeta.fromJson(e)).toList(); - } } diff --git a/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart b/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart index cdb233ea..ee88fd31 100644 --- a/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart +++ b/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart @@ -3,37 +3,41 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; extension GetSharedPicklists on LovatAPI { - List? getCachedSharedPicklists() { - return getCachedData( - '/v1/manager/picklists', - parser: (json) => (json as List) - .map((e) => ConfiguredPicklistMeta.fromJson(e)) - .toList(), + CachedQuery> sharedPicklists() { + const path = '/v1/manager/picklists'; + return CachedQuery( + queryKey: const ['sharedPicklists'], + queryFn: () async { + final response = await get(path); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + + if (response?.body == + 'Not authortized to get mutable picklists because your not on a team') { + throw const LovatAPIException('Not on team'); + } + + throw Exception('Failed to get shared picklists'); + } + + List parsedResponse = jsonDecode(response!.body); + + debugPrint(parsedResponse.toString()); + + return parsedResponse + .map((e) => ConfiguredPicklistMeta.fromJson(e)) + .toList(); + }, + cacheReader: () => getCachedData( + path, + parser: (json) => (json as List) + .map((e) => ConfiguredPicklistMeta.fromJson(e)) + .toList(), + ), ); } - - Future> getSharedPicklists() async { - final response = await get('/v1/manager/picklists'); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - - if (response?.body == - 'Not authortized to get mutable picklists because your not on a team') { - throw const LovatAPIException('Not on team'); - } - - throw Exception('Failed to get shared picklists'); - } - - List parsedResponse = jsonDecode(response!.body); - - debugPrint(parsedResponse.toString()); - - return parsedResponse - .map((e) => ConfiguredPicklistMeta.fromJson(e)) - .toList(); - } } diff --git a/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart b/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart index c5f281a3..d234abdd 100644 --- a/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart +++ b/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart @@ -1,36 +1,32 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:scouting_dashboard_app/datatypes.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; extension GetScouterSchedule on LovatAPI { - ServerScoutSchedule? getCachedScouterSchedule(String tournamentKey) { - return getCachedData( - '/v1/manager/tournament/$tournamentKey/scoutershifts', - parser: (json) => - ServerScoutSchedule.fromJson(json as Map), + CachedQuery scouterSchedule(String tournamentKey) { + final path = '/v1/manager/tournament/$tournamentKey/scoutershifts'; + return CachedQuery( + queryKey: ['scouterSchedule', tournamentKey], + queryFn: () async { + final response = await get(path); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get scouter schedule'); + } + + final json = jsonDecode(response!.body) as Map; + + return ServerScoutSchedule.fromJson(json); + }, + cacheReader: () => getCachedData( + path, + parser: (json) => + ServerScoutSchedule.fromJson(json as Map), + ), ); } - - Future getScouterSchedule() async { - final tournament = await Tournament.getCurrent(); - - if (tournament == null) { - throw Exception('No tournament selected'); - } - - final response = - await get('/v1/manager/tournament/${tournament.key}/scoutershifts'); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get scouter schedule'); - } - - final json = jsonDecode(response!.body) as Map; - - return ServerScoutSchedule.fromJson(json); - } } diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart index 324b7af9..6bb2e97b 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/metrics.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/match.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class BreakdownDetailsReport { const BreakdownDetailsReport({ @@ -184,37 +185,35 @@ class BreakdownDetailsResponse { } extension GetBreakdownMetrics on LovatAPI { - BreakdownDetailsResponse? getCachedBreakdownDetails( + CachedQuery breakdownDetails( int teamNumber, String breakdownPath, ) { - return getCachedData( - '/v1/analysis/breakdown/team/$teamNumber/$breakdownPath', - parser: (json) => BreakdownDetailsResponse.fromJson( - (json as List).cast(), - teamNumber: teamNumber, - breakdownPath: breakdownPath, + final path = '/v1/analysis/breakdown/team/$teamNumber/$breakdownPath'; + return CachedQuery( + queryKey: ['breakdownDetails', teamNumber, breakdownPath], + queryFn: () async { + final response = await get(path); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get breakdown details'); + } + + return BreakdownDetailsResponse.fromJson( + (jsonDecode(response!.body) as List).cast(), + teamNumber: teamNumber, + breakdownPath: breakdownPath, + ); + }, + cacheReader: () => getCachedData( + path, + parser: (json) => BreakdownDetailsResponse.fromJson( + (json as List).cast(), + teamNumber: teamNumber, + breakdownPath: breakdownPath, + ), ), ); } - - Future getBreakdownDetails( - int teamNumber, - String breakdownPath, - ) async { - final response = await get( - '/v1/analysis/breakdown/team/$teamNumber/$breakdownPath', - ); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get breakdown details'); - } - - return BreakdownDetailsResponse.fromJson( - (jsonDecode(response!.body) as List).cast(), - teamNumber: teamNumber, - breakdownPath: breakdownPath, - ); - } } diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart index e3b87d1c..47649f91 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class BreakdownMetrics { const BreakdownMetrics(this._values); @@ -30,27 +31,27 @@ class BreakdownMetrics { } extension GetBreakdownMetrics on LovatAPI { - BreakdownMetrics? getCachedBreakdownMetricsByTeamNumber(int teamNumber) { - return getCachedData( - '/v1/analysis/breakdown/team/$teamNumber', - parser: (json) => BreakdownMetrics.fromJson(json as Map), + CachedQuery breakdownMetrics(int teamNumber) { + final path = '/v1/analysis/breakdown/team/$teamNumber'; + return CachedQuery( + queryKey: ['breakdownMetrics', teamNumber], + queryFn: () async { + final response = await get(path); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get breakdown metrics'); + } + + final json = jsonDecode(response!.body) as Map; + + return BreakdownMetrics.fromJson(json); + }, + cacheReader: () => getCachedData( + path, + parser: (json) => + BreakdownMetrics.fromJson(json as Map), + ), ); } - - Future getBreakdownMetricsByTeamNumber( - int teamNumber, - ) async { - final response = await get( - '/v1/analysis/breakdown/team/$teamNumber', - ); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get breakdown metrics'); - } - - final json = jsonDecode(response!.body) as Map; - - return BreakdownMetrics.fromJson(json); - } } diff --git a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart index 0e7c68a2..501b1776 100644 --- a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/metrics.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class CategoryMetrics { const CategoryMetrics(this._values); @@ -18,36 +19,36 @@ class CategoryMetrics { } extension GetCategoryMetrics on LovatAPI { - CategoryMetrics? getCachedCategoryMetricsByTeamNumber(int teamNumber) { - return getCachedData( - '/v1/analysis/category/team/$teamNumber', - parser: (json) => CategoryMetrics.fromJson(json as Map), + CachedQuery categoryMetrics(int teamNumber) { + final path = '/v1/analysis/category/team/$teamNumber'; + return CachedQuery( + queryKey: ['categoryMetrics', teamNumber], + queryFn: () async { + final response = await get(path); + + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get category metrics'); + } + + try { + final json = jsonDecode(response!.body) as Map; + + return CategoryMetrics.fromJson(json); + } on FormatException { + if (["TEAM_DOES_NOT_EXIST", "NO_DATA_FOR_TEAM"] + .contains(response!.body)) { + throw LovatAPIException(response.body); + } else { + rethrow; + } + } + }, + cacheReader: () => getCachedData( + path, + parser: (json) => + CategoryMetrics.fromJson(json as Map), + ), ); } - - Future getCategoryMetricsByTeamNumber( - int teamNumber, - ) async { - final response = await get( - '/v1/analysis/category/team/$teamNumber', - ); - - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get category metrics'); - } - - try { - final json = jsonDecode(response!.body) as Map; - - return CategoryMetrics.fromJson(json); - } on FormatException { - if (["TEAM_DOES_NOT_EXIST", "NO_DATA_FOR_TEAM"] - .contains(response!.body)) { - throw LovatAPIException(response.body); - } else { - rethrow; - } - } - } } diff --git a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart index af6900f1..3e88e66f 100644 --- a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/match.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; import 'package:scouting_dashboard_app/reusable/team_auto_paths.dart'; class MetricDataPoint { @@ -71,25 +72,29 @@ class MetricDetails { } extension GetMetricDetails on LovatAPI { - MetricDetails? getCachedMetricDetails(int teamNumber, String metricPath) { - return getCachedData( - '/v1/analysis/metric/$metricPath/team/$teamNumber', - parser: (json) => MetricDetails.fromJson(json as Map), - ); - } + CachedQuery metricDetails( + int teamNumber, + String metricPath, + ) { + final path = '/v1/analysis/metric/$metricPath/team/$teamNumber'; + return CachedQuery( + queryKey: ['metricDetails', teamNumber, metricPath], + queryFn: () async { + final response = await get(path); - Future getMetricDetails( - int teamNumber, String metricPath) async { - final response = - await get('/v1/analysis/metric/$metricPath/team/$teamNumber'); + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get metric details'); + } - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get metric details'); - } - - return MetricDetails.fromJson( - jsonDecode(response!.body) as Map, + return MetricDetails.fromJson( + jsonDecode(response!.body) as Map, + ); + }, + cacheReader: () => getCachedData( + path, + parser: (json) => MetricDetails.fromJson(json as Map), + ), ); } } diff --git a/lib/reusable/lovat_api/team_lookup/get_notes.dart b/lib/reusable/lovat_api/team_lookup/get_notes.dart index 9dcdad21..faded6da 100644 --- a/lib/reusable/lovat_api/team_lookup/get_notes.dart +++ b/lib/reusable/lovat_api/team_lookup/get_notes.dart @@ -3,41 +3,43 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/match.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; extension GetNotes on LovatAPI { - List? getCachedNotesByTeamNumber(int teamNumber) { - return getCachedData( - '/v1/analysis/notes/team/$teamNumber', - parser: (json) { - final list = json as List; - final notes = []; - for (final map in list) { - notes.addAll(Note.fromJoinedMap(map as Map)); - } - return notes; - }, - ); - } - - Future> getNotesByTeamNumber( - int teamNumber, - ) async { - final response = await get('/v1/analysis/notes/team/$teamNumber'); + CachedQuery> notes(int teamNumber) { + final path = '/v1/analysis/notes/team/$teamNumber'; + return CachedQuery( + queryKey: ['notes', teamNumber], + queryFn: () async { + final response = await get(path); - if (response?.statusCode != 200) { - debugPrint(response?.body ?? ''); - throw Exception('Failed to get notes'); - } + if (response?.statusCode != 200) { + debugPrint(response?.body ?? ''); + throw Exception('Failed to get notes'); + } - final json = jsonDecode(response!.body) as List; + final json = jsonDecode(response!.body) as List; - List notes = []; + List notes = []; - for (final map in json) { - notes.addAll(Note.fromJoinedMap(map)); - } + for (final map in json) { + notes.addAll(Note.fromJoinedMap(map)); + } - return notes; + return notes; + }, + cacheReader: () => getCachedData( + path, + parser: (json) { + final list = json as List; + final notes = []; + for (final map in list) { + notes.addAll(Note.fromJoinedMap(map as Map)); + } + return notes; + }, + ), + ); } } diff --git a/lib/reusable/stale_refresh_builder.dart b/lib/reusable/stale_refresh_builder.dart new file mode 100644 index 00000000..3877b39c --- /dev/null +++ b/lib/reusable/stale_refresh_builder.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +class CachedQuery { + const CachedQuery({ + required this.queryKey, + required this.queryFn, + this.cacheReader, + }); + + final List queryKey; + final Future Function() queryFn; + final T? Function()? cacheReader; +} + +class QueryResult { + const QueryResult({ + this.data, + this.error, + this.isFetching = false, + required this.refetch, + }); + + final T? data; + final String? error; + final bool isFetching; + final VoidCallback refetch; + + bool get hasData => data != null; + bool get hasError => error != null; +} + +class QueryCache { + static final Map _memoryCache = {}; + + static String _keyToString(List key) => jsonEncode(key); + + static T? read(List key) { + return _memoryCache[_keyToString(key)] as T?; + } + + static void write(List key, T data) { + _memoryCache[_keyToString(key)] = data; + } + + static void invalidate(List key) { + _memoryCache.remove(_keyToString(key)); + } +} + +class StaleRefreshBuilder extends StatefulWidget { + const StaleRefreshBuilder({ + super.key, + required this.query, + required this.builder, + }); + + final CachedQuery query; + final Widget Function(BuildContext context, QueryResult result) builder; + + @override + State> createState() => _StaleRefreshBuilderState(); +} + +class _StaleRefreshBuilderState extends State> { + T? _data; + String? _error; + bool _isFetching = false; + List? _activeKey; + + @override + void initState() { + super.initState(); + _fetch(); + } + + @override + void didUpdateWidget(StaleRefreshBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_keyEquals(widget.query.queryKey, oldWidget.query.queryKey)) { + setState(() { + _data = null; + _error = null; + }); + _fetch(); + } + } + + bool _keyEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + Future _fetch() async { + final key = widget.query.queryKey; + _activeKey = key; + + final fromMemory = QueryCache.read(key); + if (fromMemory != null && _data == null && _error == null && mounted) { + setState(() { + _data = fromMemory; + }); + } else if (fromMemory == null && + _data == null && + _error == null && + widget.query.cacheReader != null) { + final fromPersisted = widget.query.cacheReader!(); + if (fromPersisted != null && mounted) { + QueryCache.write(key, fromPersisted); + setState(() { + _data = fromPersisted; + }); + } + } + + if (mounted) { + setState(() { + _isFetching = true; + }); + } + + try { + final result = await widget.query.queryFn(); + if (mounted && _keyEquals(_activeKey ?? [], key)) { + QueryCache.write(key, result); + setState(() { + _data = result; + _error = null; + }); + } + } catch (e) { + if (mounted && _keyEquals(_activeKey ?? [], key)) { + if (_data == null) { + setState(() { + _error = e.toString(); + }); + } + } + } finally { + if (mounted && _keyEquals(_activeKey ?? [], key)) { + setState(() { + _isFetching = false; + }); + } + } + } + + void _refetch() { + if (!mounted) return; + setState(() { + _error = null; + }); + _fetch(); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + QueryResult( + data: _data, + error: _error, + isFetching: _isFetching, + refetch: _refetch, + ), + ); + } +} diff --git a/lib/reusable/stale_refresh_indicator.dart b/lib/reusable/stale_refresh_indicator.dart index c3680846..1ac4b987 100644 --- a/lib/reusable/stale_refresh_indicator.dart +++ b/lib/reusable/stale_refresh_indicator.dart @@ -1,16 +1,23 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; class StaleRefreshIndicator extends StatefulWidget implements PreferredSizeWidget { const StaleRefreshIndicator({ super.key, - required this.isRefreshing, + required this.isFetching, required this.hasStaleData, }); - final bool isRefreshing; + StaleRefreshIndicator.result( + QueryResult result, { + super.key, + }) : isFetching = result.isFetching, + hasStaleData = result.data != null; + + final bool isFetching; final bool hasStaleData; @override @@ -33,7 +40,7 @@ class _StaleRefreshIndicatorState extends State { @override void didUpdateWidget(StaleRefreshIndicator oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.isRefreshing != widget.isRefreshing || + if (oldWidget.isFetching != widget.isFetching || oldWidget.hasStaleData != widget.hasStaleData) { _schedule(); } @@ -41,7 +48,7 @@ class _StaleRefreshIndicatorState extends State { void _schedule() { _timer?.cancel(); - if (widget.isRefreshing && widget.hasStaleData) { + if (widget.isFetching && widget.hasStaleData) { _visible = false; _timer = Timer(const Duration(milliseconds: 1200), () { if (mounted) setState(() => _visible = true); From 1abdc252938fa30d2c7306fe98a9f6048b1fccf0 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Sat, 27 Jun 2026 18:04:11 -0700 Subject: [PATCH 08/10] Format code --- lib/pages/match_predictor.dart | 6 +- lib/pages/picklist/picklist.dart | 16 +- lib/pages/picklist/picklists.dart | 14 +- lib/pages/picklist/shared_picklist.dart | 20 +- .../scout_schedule/edit_scout_schedule.dart | 249 +++++++++--------- .../tabs/team_lookup_categories.dart | 102 +++---- .../team_lookup/team_lookup_details.dart | 194 +++++++------- 7 files changed, 313 insertions(+), 288 deletions(-) diff --git a/lib/pages/match_predictor.dart b/lib/pages/match_predictor.dart index 9ac322e3..0eb7eca8 100644 --- a/lib/pages/match_predictor.dart +++ b/lib/pages/match_predictor.dart @@ -46,11 +46,13 @@ class _MatchPredictorPageState extends State { : null; return StaleRefreshBuilder( - query: lovatAPI.matchPrediction(_teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5]), + query: lovatAPI.matchPrediction( + _teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5]), builder: (context, result) { final prediction = result.data; final notEnoughData = - result.error?.contains("Not enough data") == true && prediction == null; + result.error?.contains("Not enough data") == true && + prediction == null; if (notEnoughData) { return Scaffold( diff --git a/lib/pages/picklist/picklist.dart b/lib/pages/picklist/picklist.dart index 9d6295a9..3a753c8e 100644 --- a/lib/pages/picklist/picklist.dart +++ b/lib/pages/picklist/picklist.dart @@ -196,7 +196,8 @@ class _PicklistViewState extends State { children: data .map((teamData) => ListTile( title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), + contentPadding: + const EdgeInsets.only(left: 16, right: 4), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -217,13 +218,15 @@ class _PicklistViewState extends State { 'team': teamData.teamNumber, 'breakdown': teamData.zScoresWeighted, 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklist.meta.title, + 'picklistTitle': + widget.picklist.meta.title, }); }, icon: Icon( Icons.balance, - color: - Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), tooltip: "View ${teamData.teamNumber}'s z-scores", ), @@ -236,8 +239,9 @@ class _PicklistViewState extends State { }, icon: Icon( Icons.arrow_right, - color: - Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), tooltip: "Open team lookup for ${teamData.teamNumber}", diff --git a/lib/pages/picklist/picklists.dart b/lib/pages/picklist/picklists.dart index e34c9f06..e922a7cc 100644 --- a/lib/pages/picklist/picklists.dart +++ b/lib/pages/picklist/picklists.dart @@ -498,11 +498,10 @@ class _MutablePicklistsState extends State { SnackBar( content: Text("Error: $error", style: TextStyle( - color: themeData - .colorScheme + color: themeData.colorScheme .onErrorContainer)), - backgroundColor: - themeData.colorScheme.errorContainer, + backgroundColor: themeData + .colorScheme.errorContainer, behavior: SnackBarBehavior.floating, ), ); @@ -518,7 +517,8 @@ class _MutablePicklistsState extends State { ScaffoldMessenger.of(context); final themeData = Theme.of(context); - scaffoldMessengerState.showSnackBar(const SnackBar( + scaffoldMessengerState + .showSnackBar(const SnackBar( content: Text("Deleting..."), behavior: SnackBarBehavior.floating, )); @@ -542,8 +542,8 @@ class _MutablePicklistsState extends State { content: Text( "Error deleting: $error", style: TextStyle( - color: - themeData.colorScheme.onErrorContainer, + color: themeData + .colorScheme.onErrorContainer, ), ), behavior: SnackBarBehavior.floating, diff --git a/lib/pages/picklist/shared_picklist.dart b/lib/pages/picklist/shared_picklist.dart index 4793d7b9..59fc9b2f 100644 --- a/lib/pages/picklist/shared_picklist.dart +++ b/lib/pages/picklist/shared_picklist.dart @@ -150,7 +150,9 @@ class _SharedPicklistViewState extends State { queryKey: ['sharedPicklistAnalysis', widget.picklistMeta.id], queryFn: () async { final picklist = await widget.picklistMeta.getPicklist(); - return lovatAPI.picklistAnalysis(flagPaths, picklist.weights).queryFn(); + return lovatAPI + .picklistAnalysis(flagPaths, picklist.weights) + .queryFn(); }, ), builder: (context, result) { @@ -171,7 +173,8 @@ class _SharedPicklistViewState extends State { children: data .map((teamData) => ListTile( title: Text(teamData.teamNumber.toString()), - contentPadding: const EdgeInsets.only(left: 16, right: 4), + contentPadding: + const EdgeInsets.only(left: 16, right: 4), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -192,13 +195,15 @@ class _SharedPicklistViewState extends State { 'team': teamData.teamNumber, 'breakdown': teamData.zScoresWeighted, 'unweighted': teamData.zScoresUnweighted, - 'picklistTitle': widget.picklistMeta.title, + 'picklistTitle': + widget.picklistMeta.title, }); }, icon: Icon( Icons.balance, - color: - Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), tooltip: "View ${teamData.teamNumber}'s z-scores", ), @@ -211,8 +216,9 @@ class _SharedPicklistViewState extends State { }, icon: Icon( Icons.arrow_right, - color: - Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), tooltip: "Open team lookup for ${teamData.teamNumber}", diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index cfb01d98..02b7d7aa 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -43,143 +43,146 @@ class EditScoutSchedulePage extends StatelessWidget { return StaleRefreshBuilder( query: lovatAPI.scouterSchedule(tournament.key), - builder: (context, result) { - final scoutSchedule = result.data; - Widget body = SkeletonListView( - itemBuilder: (context, index) => SkeletonListTile(), - ); - - if (result.hasError && scoutSchedule == null) { - body = FriendlyErrorView.result( - result, - retryLabel: "Reload", - ); - } - - if (scoutSchedule != null) { - body = Stack( - children: [ - ListView.builder( - itemBuilder: (context, index) { - final shift = scoutSchedule.shifts[index]; - return Dismissible( - key: Key(shift.id), - direction: DismissDirection.endToStart, - background: Container( - color: Colors.red[900], - child: const Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.delete), - SizedBox(width: 30), - ], + builder: (context, result) { + final scoutSchedule = result.data; + Widget body = SkeletonListView( + itemBuilder: (context, index) => SkeletonListTile(), + ); + + if (result.hasError && scoutSchedule == null) { + body = FriendlyErrorView.result( + result, + retryLabel: "Reload", + ); + } + + if (scoutSchedule != null) { + body = Stack( + children: [ + ListView.builder( + itemBuilder: (context, index) { + final shift = scoutSchedule.shifts[index]; + return Dismissible( + key: Key(shift.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red[900], + child: const Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon(Icons.delete), + SizedBox(width: 30), + ], + ), ), ), - ), - onUpdate: (details) { - if ((details.reached && !details.previousReached) || - (!details.reached && details.previousReached)) { - HapticFeedback.lightImpact(); - } - }, - confirmDismiss: (direction) async { + onUpdate: (details) { + if ((details.reached && !details.previousReached) || + (!details.reached && details.previousReached)) { + HapticFeedback.lightImpact(); + } + }, + confirmDismiss: (direction) async { + try { + await lovatAPI.deleteScoutScheduleShift(shift); + result.refetch(); + return true; + } catch (e) { + if (!context.mounted) return false; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Failed to delete shift")), + ); + return false; + } + }, + child: ListTile( + title: Text("${shift.start} to ${shift.end}"), + subtitle: Text(shift.allScoutsList), + onTap: () { + Navigator.of(context).pushWidget( + ScoutShiftEditor( + initialShift: shift.copy(), + onSubmit: (shift) async { + if (shift is ServerScoutingShift) { + try { + await lovatAPI + .updateScouterScheduleShift(shift); + result.refetch(); + } on LovatAPIException catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: + Text("Failed to update shift")), + ); + } + } else { + throw Exception("Invalid shift type"); + } + }, + ), + ); + }, + ), + ); + }, + itemCount: scoutSchedule.shifts.length, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text("Edit Scout Schedule"), + ), + body: body, + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + Navigator.of(context).pushWidget( + ScoutShiftEditor( + onSubmit: (shift) async { try { - await lovatAPI.deleteScoutScheduleShift(shift); + await lovatAPI.createScoutScheduleShift(shift); result.refetch(); - return true; - } catch (e) { - if (!context.mounted) return false; + } on LovatAPIException catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (_) { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text("Failed to delete shift")), + content: Text("Failed to create shift")), ); - return false; } }, - child: ListTile( - title: Text("${shift.start} to ${shift.end}"), - subtitle: Text(shift.allScoutsList), - onTap: () { - Navigator.of(context).pushWidget( - ScoutShiftEditor( - initialShift: shift.copy(), - onSubmit: (shift) async { - if (shift is ServerScoutingShift) { - try { - await lovatAPI - .updateScouterScheduleShift(shift); - result.refetch(); - } on LovatAPIException catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.message)), - ); - } catch (_) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text("Failed to update shift")), - ); - } - } else { - throw Exception("Invalid shift type"); - } - }, - ), - ); - }, - ), - ); + ), + ); }, - itemCount: scoutSchedule.shifts.length, ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator.result(result), - ), - ], - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text("Edit Scout Schedule"), - ), - body: body, - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add), - onPressed: () { - Navigator.of(context).pushWidget( - ScoutShiftEditor( - onSubmit: (shift) async { - try { - await lovatAPI.createScoutScheduleShift(shift); - result.refetch(); - } on LovatAPIException catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.message)), - ); - } catch (_) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Failed to create shift")), - ); - } - }, - ), - ); - }, - ), + ); + }, ); }, ); - }, - ); } } diff --git a/lib/pages/team_lookup/tabs/team_lookup_categories.dart b/lib/pages/team_lookup/tabs/team_lookup_categories.dart index 0ea3b05c..5fe2e156 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_categories.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_categories.dart @@ -25,57 +25,57 @@ class TeamLookupCategoriesTab extends StatelessWidget { return Stack( children: [ ScrollablePageBody( - children: [ - MetricCategoryList( - metricCategories: metricCategories - .map((category) => MetricCategory( - categoryName: category.localizedName, - metricTiles: category.metrics - .where( - (metric) => metric.hideOverview == false) - .map( - (metric) => MetricTile( - value: (() { - try { - return metric - .valueVizualizationBuilder( - data.valueForMetric(metric)); - } catch (_) { - return "--"; - } - })(), - label: metric.abbreviatedLocalizedName, - onTap: metric.hideDetails - ? null - : () { - Navigator.of(context).pushNamed( - "/team_lookup_details", - arguments: { - 'category': category, - 'metric': metric.path, - 'team': team, - }); - }, - ), - ) - .toList(), - onTap: category.metrics - .where((metric) => !metric.hideDetails) - .isEmpty - ? null - : () { - Navigator.of(context).pushNamed( - "/team_lookup_details", - arguments: { - 'category': category, - 'team': team, - }); - }, - )) - .toList(), - ), - ], - ), + children: [ + MetricCategoryList( + metricCategories: metricCategories + .map((category) => MetricCategory( + categoryName: category.localizedName, + metricTiles: category.metrics + .where( + (metric) => metric.hideOverview == false) + .map( + (metric) => MetricTile( + value: (() { + try { + return metric + .valueVizualizationBuilder( + data.valueForMetric(metric)); + } catch (_) { + return "--"; + } + })(), + label: metric.abbreviatedLocalizedName, + onTap: metric.hideDetails + ? null + : () { + Navigator.of(context).pushNamed( + "/team_lookup_details", + arguments: { + 'category': category, + 'metric': metric.path, + 'team': team, + }); + }, + ), + ) + .toList(), + onTap: category.metrics + .where((metric) => !metric.hideDetails) + .isEmpty + ? null + : () { + Navigator.of(context).pushNamed( + "/team_lookup_details", + arguments: { + 'category': category, + 'team': team, + }); + }, + )) + .toList(), + ), + ], + ), Positioned( top: 0, left: 0, diff --git a/lib/pages/team_lookup/team_lookup_details.dart b/lib/pages/team_lookup/team_lookup_details.dart index 30f6ab30..f4922bca 100644 --- a/lib/pages/team_lookup/team_lookup_details.dart +++ b/lib/pages/team_lookup/team_lookup_details.dart @@ -157,105 +157,115 @@ class AnalysisOverview extends StatelessWidget { child: Column( children: [ Row(children: [ - if (d.hasResult) - valueBox( - context, - Text( - metric.valueVizualizationBuilder(d.result), - style: Theme.of(context).textTheme.headlineSmall!.merge( - TextStyle( - color: - Theme.of(context).colorScheme.onPrimaryContainer), + if (d.hasResult) + valueBox( + context, + Text( + metric.valueVizualizationBuilder(d.result), + style: + Theme.of(context).textTheme.headlineSmall!.merge( + TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer), + ), ), - ), - "This team", - false, - ), - if (d.hasResult) const SizedBox(width: 10), - if (d.hasAll) - Flexible( - flex: 5, - fit: FlexFit.tight, - child: valueBox( - context, - Text( - metric.valueVizualizationBuilder(d.all), - style: Theme.of(context).textTheme.headlineSmall!.merge( - TextStyle( - color: Theme.of(context).colorScheme.onPrimary), - ), - ), - "All teams", - true, - ), - ), - if (d.hasAll) const SizedBox(width: 10), - if (d.hasDifference) - Flexible( - flex: 6, - fit: FlexFit.tight, - child: valueBox( - context, - d.difference == null - ? Text( - "--", - style: Theme.of(context).textTheme.headlineSmall!.merge( + "This team", + false, + ), + if (d.hasResult) const SizedBox(width: 10), + if (d.hasAll) + Flexible( + flex: 5, + fit: FlexFit.tight, + child: valueBox( + context, + Text( + metric.valueVizualizationBuilder(d.all), + style: Theme.of(context) + .textTheme + .headlineSmall! + .merge( TextStyle( color: Theme.of(context) .colorScheme - .onPrimaryContainer), + .onPrimary), ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - d.difference!.isNegative - ? Icons.arrow_drop_down - : Icons.arrow_drop_up, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - Text( - metric - .valueVizualizationBuilder(d.difference!.abs()), - style: Theme.of(context) - .textTheme - .headlineSmall! - .merge( - TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer), - ), - ), - ], ), - "Difference", - false, - ), - ), - ]), - if (d.array.isNotEmpty) ...[ - const SizedBox(height: 10), - sparkline(context, d, metric.max), - ], - if (d.paths.isNotEmpty) - TeamAutoPaths( - autoPaths: d.paths, + "All teams", + true, + ), + ), + if (d.hasAll) const SizedBox(width: 10), + if (d.hasDifference) + Flexible( + flex: 6, + fit: FlexFit.tight, + child: valueBox( + context, + d.difference == null + ? Text( + "--", + style: Theme.of(context) + .textTheme + .headlineSmall! + .merge( + TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + d.difference!.isNegative + ? Icons.arrow_drop_down + : Icons.arrow_drop_up, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + Text( + metric.valueVizualizationBuilder( + d.difference!.abs()), + style: Theme.of(context) + .textTheme + .headlineSmall! + .merge( + TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer), + ), + ), + ], + ), + "Difference", + false, + ), + ), + ]), + if (d.array.isNotEmpty) ...[ + const SizedBox(height: 10), + sparkline(context, d, metric.max), + ], + if (d.paths.isNotEmpty) + TeamAutoPaths( + autoPaths: d.paths, + ), + ], ), - ], - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: StaleRefreshIndicator.result(result), - ), - ], - ); + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: StaleRefreshIndicator.result(result), + ), + ], + ); }, ); } From dbb6d730382c5f451903f35d6aa45af69eaadb81 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Wed, 1 Jul 2026 16:58:32 -0700 Subject: [PATCH 09/10] Improve naming for lovatapi query methods --- lib/pages/alliance.dart | 2 +- lib/pages/archived_scouters.dart | 2 +- lib/pages/match_predictor.dart | 2 +- lib/pages/picklist/picklist.dart | 2 +- lib/pages/picklist/picklist_models.dart | 3 ++- lib/pages/picklist/picklists.dart | 4 ++-- lib/pages/picklist/shared_picklist.dart | 2 +- lib/pages/scout_schedule/edit_scout_schedule.dart | 2 +- lib/pages/scouters.dart | 2 +- lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart | 2 +- lib/pages/team_lookup/tabs/team_lookup_categories.dart | 2 +- lib/pages/team_lookup/tabs/team_lookup_notes.dart | 2 +- lib/pages/team_lookup/team_lookup_breakdown_details.dart | 2 +- lib/pages/team_lookup/team_lookup_details.dart | 2 +- lib/reusable/lovat_api/get_alliance_analysis.dart | 4 ++-- lib/reusable/lovat_api/get_match_prediction.dart | 4 ++-- lib/reusable/lovat_api/get_scouter_overviews.dart | 4 ++-- lib/reusable/lovat_api/picklists/get_picklist_analysis.dart | 6 +++--- .../lovat_api/picklists/mutable/get_mutable_picklists.dart | 4 ++-- .../lovat_api/picklists/shared/get_shared_picklists.dart | 4 ++-- .../lovat_api/scouter_schedule/get_scouter_schedule.dart | 4 ++-- .../lovat_api/team_lookup/get_breakdown_details.dart | 4 ++-- .../lovat_api/team_lookup/get_breakdown_metrics.dart | 4 ++-- .../lovat_api/team_lookup/get_category_metrics.dart | 4 ++-- lib/reusable/lovat_api/team_lookup/get_metric_details.dart | 4 ++-- lib/reusable/lovat_api/team_lookup/get_notes.dart | 4 ++-- 26 files changed, 41 insertions(+), 40 deletions(-) diff --git a/lib/pages/alliance.dart b/lib/pages/alliance.dart index 5bca60eb..21a71782 100644 --- a/lib/pages/alliance.dart +++ b/lib/pages/alliance.dart @@ -31,7 +31,7 @@ class _AlliancePageState extends State { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.allianceAnalysis(teams), + query: lovatAPI.allianceAnalysisQuery(teams), builder: (context, result) { final data = result.data; Widget body; diff --git a/lib/pages/archived_scouters.dart b/lib/pages/archived_scouters.dart index 02764f02..a0c864da 100644 --- a/lib/pages/archived_scouters.dart +++ b/lib/pages/archived_scouters.dart @@ -29,7 +29,7 @@ class _ArchivedScoutersPageState extends State { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.scouterOverviews(archivedScouters: true), + query: lovatAPI.scouterOverviewsQuery(archivedScouters: true), builder: (context, result) { final scouterOverviews = result.data; final tournament = Tournament.currentSync; diff --git a/lib/pages/match_predictor.dart b/lib/pages/match_predictor.dart index 0eb7eca8..102ef781 100644 --- a/lib/pages/match_predictor.dart +++ b/lib/pages/match_predictor.dart @@ -46,7 +46,7 @@ class _MatchPredictorPageState extends State { : null; return StaleRefreshBuilder( - query: lovatAPI.matchPrediction( + query: lovatAPI.matchPredictionQuery( _teams[0], _teams[1], _teams[2], _teams[3], _teams[4], _teams[5]), builder: (context, result) { final prediction = result.data; diff --git a/lib/pages/picklist/picklist.dart b/lib/pages/picklist/picklist.dart index 3a753c8e..0f53232b 100644 --- a/lib/pages/picklist/picklist.dart +++ b/lib/pages/picklist/picklist.dart @@ -177,7 +177,7 @@ class _PicklistViewState extends State { final flagPaths = flags!.map((e) => e.type.path).toList(); return StaleRefreshBuilder( - query: lovatAPI.picklistAnalysis(flagPaths, widget.picklist.weights), + query: lovatAPI.picklistAnalysisQuery(flagPaths, widget.picklist.weights), builder: (context, result) { final data = result.data; if (result.hasError && data == null) { diff --git a/lib/pages/picklist/picklist_models.dart b/lib/pages/picklist/picklist_models.dart index 667be2ad..8b7de381 100644 --- a/lib/pages/picklist/picklist_models.dart +++ b/lib/pages/picklist/picklist_models.dart @@ -57,7 +57,8 @@ class ConfiguredPicklist { String? author; Future> fetchTeamRankings() async { - final analysis = await lovatAPI.picklistAnalysis([], weights).queryFn(); + final analysis = + await lovatAPI.picklistAnalysisQuery([], weights).queryFn(); if (analysis.isEmpty) { throw const LovatAPIException("Failed to fetch team rankings."); diff --git a/lib/pages/picklist/picklists.dart b/lib/pages/picklist/picklists.dart index e922a7cc..62a5b0de 100644 --- a/lib/pages/picklist/picklists.dart +++ b/lib/pages/picklist/picklists.dart @@ -244,7 +244,7 @@ class SharedPicklists extends StatelessWidget { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.sharedPicklists(), + query: lovatAPI.sharedPicklistsQuery(), builder: (context, result) { final picklists = result.data; if (result.hasError && picklists == null) { @@ -415,7 +415,7 @@ class _MutablePicklistsState extends State { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.mutablePicklists(), + query: lovatAPI.mutablePicklistsQuery(), builder: (context, result) { final picklistsMeta = result.data; if (result.hasError && picklistsMeta == null) { diff --git a/lib/pages/picklist/shared_picklist.dart b/lib/pages/picklist/shared_picklist.dart index 59fc9b2f..ce05bc8c 100644 --- a/lib/pages/picklist/shared_picklist.dart +++ b/lib/pages/picklist/shared_picklist.dart @@ -151,7 +151,7 @@ class _SharedPicklistViewState extends State { queryFn: () async { final picklist = await widget.picklistMeta.getPicklist(); return lovatAPI - .picklistAnalysis(flagPaths, picklist.weights) + .picklistAnalysisQuery(flagPaths, picklist.weights) .queryFn(); }, ), diff --git a/lib/pages/scout_schedule/edit_scout_schedule.dart b/lib/pages/scout_schedule/edit_scout_schedule.dart index 02b7d7aa..9c1479da 100644 --- a/lib/pages/scout_schedule/edit_scout_schedule.dart +++ b/lib/pages/scout_schedule/edit_scout_schedule.dart @@ -42,7 +42,7 @@ class EditScoutSchedulePage extends StatelessWidget { } return StaleRefreshBuilder( - query: lovatAPI.scouterSchedule(tournament.key), + query: lovatAPI.scouterScheduleQuery(tournament.key), builder: (context, result) { final scoutSchedule = result.data; Widget body = SkeletonListView( diff --git a/lib/pages/scouters.dart b/lib/pages/scouters.dart index b1add0e0..14cc37c8 100644 --- a/lib/pages/scouters.dart +++ b/lib/pages/scouters.dart @@ -30,7 +30,7 @@ class _ScoutersPageState extends State { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.scouterOverviews(), + query: lovatAPI.scouterOverviewsQuery(), builder: (context, result) { final scouterOverviews = result.data; final tournament = Tournament.currentSync; diff --git a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart index db037414..b90bafb5 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart @@ -19,7 +19,7 @@ class TeamLookupBreakdownsTab extends StatelessWidget { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.breakdownMetrics(team), + query: lovatAPI.breakdownMetricsQuery(team), builder: (context, result) { final data = result.data; final error = result.error; diff --git a/lib/pages/team_lookup/tabs/team_lookup_categories.dart b/lib/pages/team_lookup/tabs/team_lookup_categories.dart index 5fe2e156..096783b2 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_categories.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_categories.dart @@ -17,7 +17,7 @@ class TeamLookupCategoriesTab extends StatelessWidget { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.categoryMetrics(team), + query: lovatAPI.categoryMetricsQuery(team), builder: (context, result) { final data = result.data; final error = result.error; diff --git a/lib/pages/team_lookup/tabs/team_lookup_notes.dart b/lib/pages/team_lookup/tabs/team_lookup_notes.dart index 45456b35..27bd74b5 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_notes.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_notes.dart @@ -19,7 +19,7 @@ class TeamLookupNotesTab extends StatelessWidget { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.notes(team), + query: lovatAPI.notesQuery(team), builder: (context, result) { final data = result.data; final error = result.error; diff --git a/lib/pages/team_lookup/team_lookup_breakdown_details.dart b/lib/pages/team_lookup/team_lookup_breakdown_details.dart index 13a503a5..e78476d9 100644 --- a/lib/pages/team_lookup/team_lookup_breakdown_details.dart +++ b/lib/pages/team_lookup/team_lookup_breakdown_details.dart @@ -26,7 +26,7 @@ class BreakdownDetailsPage extends StatelessWidget { title: Text("$team - ${breakdownIdentity.localizedName}"), ), body: StaleRefreshBuilder( - query: lovatAPI.breakdownDetails(team, breakdownIdentity.path), + query: lovatAPI.breakdownDetailsQuery(team, breakdownIdentity.path), builder: (context, result) { final data = result.data; if (result.hasError && !result.hasData) { diff --git a/lib/pages/team_lookup/team_lookup_details.dart b/lib/pages/team_lookup/team_lookup_details.dart index f4922bca..900b26cf 100644 --- a/lib/pages/team_lookup/team_lookup_details.dart +++ b/lib/pages/team_lookup/team_lookup_details.dart @@ -137,7 +137,7 @@ class AnalysisOverview extends StatelessWidget { @override Widget build(BuildContext context) { return StaleRefreshBuilder( - query: lovatAPI.metricDetails(teamNumber, metric.path), + query: lovatAPI.metricDetailsQuery(teamNumber, metric.path), builder: (context, result) { final data = result.data; final error = result.error; diff --git a/lib/reusable/lovat_api/get_alliance_analysis.dart b/lib/reusable/lovat_api/get_alliance_analysis.dart index 6a1c3c2a..d1d81d1d 100644 --- a/lib/reusable/lovat_api/get_alliance_analysis.dart +++ b/lib/reusable/lovat_api/get_alliance_analysis.dart @@ -67,8 +67,8 @@ class AllianceAnalysis { } } -extension GetAllianceAnalysis on LovatAPI { - CachedQuery allianceAnalysis(List teams) { +extension AllianceAnalysisQuery on LovatAPI { + CachedQuery allianceAnalysisQuery(List teams) { const path = '/v1/analysis/alliance'; final query = { 'teamOne': teams[0].toString(), diff --git a/lib/reusable/lovat_api/get_match_prediction.dart b/lib/reusable/lovat_api/get_match_prediction.dart index febd9ddb..4f80cc60 100644 --- a/lib/reusable/lovat_api/get_match_prediction.dart +++ b/lib/reusable/lovat_api/get_match_prediction.dart @@ -32,8 +32,8 @@ class MatchPrediction { } } -extension GetMatchPrediction on LovatAPI { - CachedQuery matchPrediction( +extension MatchPredictionQuery on LovatAPI { + CachedQuery matchPredictionQuery( int red1, int red2, int red3, diff --git a/lib/reusable/lovat_api/get_scouter_overviews.dart b/lib/reusable/lovat_api/get_scouter_overviews.dart index 94e183ff..20e141ab 100644 --- a/lib/reusable/lovat_api/get_scouter_overviews.dart +++ b/lib/reusable/lovat_api/get_scouter_overviews.dart @@ -5,8 +5,8 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; -extension GetScouterOverviews on LovatAPI { - CachedQuery> scouterOverviews( +extension ScouterOverviewsQuery on LovatAPI { + CachedQuery> scouterOverviewsQuery( {bool archivedScouters = false}) { const path = '/v1/manager/scouterspage'; return CachedQuery( diff --git a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart index 219b39e6..ed745fe5 100644 --- a/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart +++ b/lib/reusable/lovat_api/picklists/get_picklist_analysis.dart @@ -58,8 +58,8 @@ class PicklistAnalysisTeam { } } -extension GetPicklistAnalysis on LovatAPI { - CachedQuery> picklistAnalysis( +extension PicklistAnalysisQuery on LovatAPI { + CachedQuery> picklistAnalysisQuery( List flags, List weights, ) { @@ -123,7 +123,7 @@ extension GetPicklistAnalysis on LovatAPI { required List weights, }) async { final flagPaths = flags.map((e) => e.type.path).toList(); - final teams = await picklistAnalysis(flagPaths, weights).queryFn(); + final teams = await picklistAnalysisQuery(flagPaths, weights).queryFn(); final List columns = [ "teamNumber", diff --git a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart index fac82e4c..4d957cef 100644 --- a/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart +++ b/lib/reusable/lovat_api/picklists/mutable/get_mutable_picklists.dart @@ -5,8 +5,8 @@ import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; -extension GetMutablePicklists on LovatAPI { - CachedQuery> mutablePicklists() { +extension MutablePicklistsQuery on LovatAPI { + CachedQuery> mutablePicklistsQuery() { const path = '/v1/manager/mutablepicklists'; return CachedQuery( queryKey: const ['mutablePicklists'], diff --git a/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart b/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart index ee88fd31..506f8112 100644 --- a/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart +++ b/lib/reusable/lovat_api/picklists/shared/get_shared_picklists.dart @@ -5,8 +5,8 @@ import 'package:scouting_dashboard_app/pages/picklist/picklist_models.dart'; import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; -extension GetSharedPicklists on LovatAPI { - CachedQuery> sharedPicklists() { +extension SharedPicklistsQuery on LovatAPI { + CachedQuery> sharedPicklistsQuery() { const path = '/v1/manager/picklists'; return CachedQuery( queryKey: const ['sharedPicklists'], diff --git a/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart b/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart index d234abdd..d87558fd 100644 --- a/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart +++ b/lib/reusable/lovat_api/scouter_schedule/get_scouter_schedule.dart @@ -5,8 +5,8 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/scout_schedule.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; -extension GetScouterSchedule on LovatAPI { - CachedQuery scouterSchedule(String tournamentKey) { +extension ScouterScheduleQuery on LovatAPI { + CachedQuery scouterScheduleQuery(String tournamentKey) { final path = '/v1/manager/tournament/$tournamentKey/scoutershifts'; return CachedQuery( queryKey: ['scouterSchedule', tournamentKey], diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart index 6bb2e97b..dcb2f3b8 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_details.dart @@ -184,8 +184,8 @@ class BreakdownDetailsResponse { } } -extension GetBreakdownMetrics on LovatAPI { - CachedQuery breakdownDetails( +extension BreakdownDetailsQuery on LovatAPI { + CachedQuery breakdownDetailsQuery( int teamNumber, String breakdownPath, ) { diff --git a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart index 47649f91..0aaad3d5 100644 --- a/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_breakdown_metrics.dart @@ -30,8 +30,8 @@ class BreakdownMetrics { _values[breakdownPath] == null || _values[breakdownPath]!.isEmpty; } -extension GetBreakdownMetrics on LovatAPI { - CachedQuery breakdownMetrics(int teamNumber) { +extension BreakdownMetricsQuery on LovatAPI { + CachedQuery breakdownMetricsQuery(int teamNumber) { final path = '/v1/analysis/breakdown/team/$teamNumber'; return CachedQuery( queryKey: ['breakdownMetrics', teamNumber], diff --git a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart index 501b1776..771bc46f 100644 --- a/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart +++ b/lib/reusable/lovat_api/team_lookup/get_category_metrics.dart @@ -18,8 +18,8 @@ class CategoryMetrics { dynamic valueForPath(String path) => _values[path]; } -extension GetCategoryMetrics on LovatAPI { - CachedQuery categoryMetrics(int teamNumber) { +extension CategoryMetricsQuery on LovatAPI { + CachedQuery categoryMetricsQuery(int teamNumber) { final path = '/v1/analysis/category/team/$teamNumber'; return CachedQuery( queryKey: ['categoryMetrics', teamNumber], diff --git a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart index 3e88e66f..a98adb69 100644 --- a/lib/reusable/lovat_api/team_lookup/get_metric_details.dart +++ b/lib/reusable/lovat_api/team_lookup/get_metric_details.dart @@ -71,8 +71,8 @@ class MetricDetails { } } -extension GetMetricDetails on LovatAPI { - CachedQuery metricDetails( +extension MetricDetailsQuery on LovatAPI { + CachedQuery metricDetailsQuery( int teamNumber, String metricPath, ) { diff --git a/lib/reusable/lovat_api/team_lookup/get_notes.dart b/lib/reusable/lovat_api/team_lookup/get_notes.dart index faded6da..aed2ec33 100644 --- a/lib/reusable/lovat_api/team_lookup/get_notes.dart +++ b/lib/reusable/lovat_api/team_lookup/get_notes.dart @@ -5,8 +5,8 @@ import 'package:scouting_dashboard_app/reusable/lovat_api/lovat_api.dart'; import 'package:scouting_dashboard_app/reusable/models/match.dart'; import 'package:scouting_dashboard_app/reusable/stale_refresh_builder.dart'; -extension GetNotes on LovatAPI { - CachedQuery> notes(int teamNumber) { +extension NotesQuery on LovatAPI { + CachedQuery> notesQuery(int teamNumber) { final path = '/v1/analysis/notes/team/$teamNumber'; return CachedQuery( queryKey: ['notes', teamNumber], From e144a6b696a01200a077569274f1d55bf9876ddb Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Wed, 1 Jul 2026 17:13:08 -0700 Subject: [PATCH 10/10] Fix match schedule divider styling --- lib/pages/match_schedule.dart | 29 +++++++++++++++++-- .../tabs/team_lookup_breakdowns.dart | 4 +-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/pages/match_schedule.dart b/lib/pages/match_schedule.dart index 00f34ee2..fd4fbddb 100644 --- a/lib/pages/match_schedule.dart +++ b/lib/pages/match_schedule.dart @@ -45,6 +45,7 @@ class _MatchSchedulePageState extends State { bool showProgressIndicator = false; bool fabVisible = false; + bool scrolled = false; Future checkRole() async { final cached = lovatAPI.getCachedUserProfile(); @@ -301,9 +302,23 @@ class _MatchSchedulePageState extends State { ], ), ), - StaleRefreshIndicator( - isFetching: showProgressIndicator, - hasStaleData: matches != null, + Stack( + children: [ + if (!scrolled) + Align( + alignment: Alignment.bottomCenter, + child: Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + ), + StaleRefreshIndicator( + isFetching: showProgressIndicator, + hasStaleData: matches != null, + ), + ], ), ], ), @@ -358,6 +373,14 @@ class _MatchSchedulePageState extends State { updateFabVisibility(notification); + final isScrolled = + notification.metrics.pixels > 0; + if (isScrolled != scrolled) { + setState(() { + scrolled = isScrolled; + }); + } + return false; }, child: Matches( diff --git a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart index b90bafb5..91f992da 100644 --- a/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart +++ b/lib/pages/team_lookup/tabs/team_lookup_breakdowns.dart @@ -232,13 +232,13 @@ class Breakdown extends StatelessWidget { Text( "${(value * 100).round()}%", style: Theme.of(context).textTheme.titleMedium, - overflow: TextOverflow.ellipsis, + overflow: TextOverflow.fade, maxLines: 1, ), Text( name, style: Theme.of(context).textTheme.labelMedium, - overflow: TextOverflow.ellipsis, + overflow: TextOverflow.fade, maxLines: 1, ), ],