From aef0dd38c77d64f6a98893dd20445458e5541e90 Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Mon, 18 May 2020 20:40:47 +0200
Subject: [PATCH 01/11] Actually using forward destinations titleOrIp
---
.../pages/summary/widgets/forward_destinations_tile.dart | 2 +-
.../pihole_api/data/models/forward_destinations.dart | 9 ++++++---
test/fixtures/get_forward_destinations.json | 3 ++-
test/model_test.dart | 2 +-
4 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/lib/features/home/presentation/pages/summary/widgets/forward_destinations_tile.dart b/lib/features/home/presentation/pages/summary/widgets/forward_destinations_tile.dart
index ef35215b..879cf834 100644
--- a/lib/features/home/presentation/pages/summary/widgets/forward_destinations_tile.dart
+++ b/lib/features/home/presentation/pages/summary/widgets/forward_destinations_tile.dart
@@ -37,7 +37,7 @@ class ForwardDestinationsTile extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: GraphLegendItem(
index: index,
- title: forwardDestination.title,
+ title: forwardDestination.titleOrIp,
subtitle: '$percentage%',
color:
ForwardDestinationsPieChart.colorAtIndex(index),
diff --git a/lib/features/pihole_api/data/models/forward_destinations.dart b/lib/features/pihole_api/data/models/forward_destinations.dart
index 9f2dcf4c..7beece79 100644
--- a/lib/features/pihole_api/data/models/forward_destinations.dart
+++ b/lib/features/pihole_api/data/models/forward_destinations.dart
@@ -18,7 +18,7 @@ abstract class ForwardDestination extends MapModel
factory ForwardDestination.fromJson(Map json) =>
_$ForwardDestinationFromJson(json);
- String get titleOrIp => (title?.isEmpty ?? true) ? ip : '${ip} (${title})';
+ String get titleOrIp => (title?.isEmpty ?? true) ? ip : title;
}
Map _valueToForwardDestinations(dynamic value) {
@@ -55,6 +55,9 @@ abstract class ForwardDestinationsResult extends MapModel
Map forwardDestinations}) =
_ForwardDestinationsResult;
- factory ForwardDestinationsResult.fromJson(Map json) =>
- _$ForwardDestinationsResultFromJson(json);
+ factory ForwardDestinationsResult.fromJson(Map json) {
+ print('json: $json');
+
+ return _$ForwardDestinationsResultFromJson(json);
+ }
}
diff --git a/test/fixtures/get_forward_destinations.json b/test/fixtures/get_forward_destinations.json
index a064496f..78278ac4 100644
--- a/test/fixtures/get_forward_destinations.json
+++ b/test/fixtures/get_forward_destinations.json
@@ -5,6 +5,7 @@
"dns.google|8.8.4.4": 40.21,
"dns.google|8.8.8.8": 13.36,
"hi.google|1.2.3.4": 5,
- "bye.google|5.6.7.8": 5.0
+ "bye.google|5.6.7.8": 5.0,
+ "1.2.3.4": 1.23
}
}
\ No newline at end of file
diff --git a/test/model_test.dart b/test/model_test.dart
index c1d5a014..0eac8436 100644
--- a/test/model_test.dart
+++ b/test/model_test.dart
@@ -65,7 +65,7 @@ void main() {
testMapModel(
'get_query_sources.json', (json) => TopSourcesResult.fromJson(json));
testMapModel('single_forward_destination.json',
- (json) => ForwardDestination.fromJson(json));
+ (json) => ForwardDestination.fromJson(json));
testMapModel('get_forward_destinations.json',
(json) => ForwardDestinationsResult.fromJson(json));
testListModel(
From 3e845c81d8e75a5ffd59f33bb5c392c7746848b2 Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Mon, 18 May 2020 20:52:07 +0200
Subject: [PATCH 02/11] Remove index shift from QueryStatus toJson/fromJson,
fixes #82
---
lib/features/pihole_api/data/models/query_data.dart | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/features/pihole_api/data/models/query_data.dart b/lib/features/pihole_api/data/models/query_data.dart
index 3ff92866..12d808d0 100644
--- a/lib/features/pihole_api/data/models/query_data.dart
+++ b/lib/features/pihole_api/data/models/query_data.dart
@@ -56,7 +56,7 @@ QueryStatus _stringToQueryStatus(String json) {
if (index > QueryStatus.values.length || index == 0)
return QueryStatus.values.first;
- return QueryStatus.values[index - 1];
+ return QueryStatus.values[index];
} catch (e) {
print('_stringToQueryStatus failed: $e');
return QueryStatus.Unknown;
@@ -103,7 +103,7 @@ abstract class QueryData extends ListModel implements _$QueryData {
_$QueryTypeEnumMap[queryType],
domain,
clientName,
- '${QueryStatus.values.indexOf(queryStatus) + 1}',
+ '${QueryStatus.values.indexOf(queryStatus)}',
'${DnsSecStatus.values.indexOf(dnsSecStatus)}',
'$replyTextIndex',
'${(replyDuration.inMicroseconds / 100).round()}',
From b6cc2280c36336f8c59e48d72af621b3cce9d044 Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Mon, 18 May 2020 22:33:18 +0200
Subject: [PATCH 03/11] Adding QueryLogPage
---
lib/constants.dart | 1 +
.../pihole_api/blocs/query_log_bloc.dart | 75 +++++++++
.../data/datasources/api_data_source.dart | 11 +-
.../data/datasources/api_data_source_dio.dart | 17 +-
.../data/repositories/api_repository.dart | 4 +
.../repositories/api_repository_impl.dart | 15 +-
.../presentation/pages/query_log_page.dart | 93 +++++++++++
.../widgets/many_query_tiles_builder.dart | 3 +
.../query_log_page_overflow_refresher.dart | 61 +++++++
.../widgets/single_query_data_tile.dart | 6 +-
.../presentation/widgets/default_drawer.dart | 5 +
.../routing/services/router_service.dart | 1 +
.../services/router_service_sailor.dart | 6 +
.../pages/user_preferences_page.dart | 73 +-------
.../preferences/theme_radio_preferences.dart | 45 +++++
.../use_numbers_api_switch_preference.dart | 20 +++
.../settings/services/preference_service.dart | 6 +
.../services/preference_service_impl.dart | 7 +
.../pihole_api/blocs/query_log_bloc_test.dart | 126 ++++++++++++++
.../datasources/api_data_source_dio_test.dart | 30 +++-
.../api_repository_impl_test.dart | 156 ++++++++++++++----
21 files changed, 649 insertions(+), 112 deletions(-)
create mode 100644 lib/features/pihole_api/blocs/query_log_bloc.dart
create mode 100644 lib/features/pihole_api/presentation/pages/query_log_page.dart
create mode 100644 lib/features/pihole_api/presentation/widgets/query_log_page_overflow_refresher.dart
create mode 100644 lib/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart
create mode 100644 lib/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart
create mode 100644 test/features/pihole_api/blocs/query_log_bloc_test.dart
diff --git a/lib/constants.dart b/lib/constants.dart
index 979e4b71..d3764d07 100644
--- a/lib/constants.dart
+++ b/lib/constants.dart
@@ -25,6 +25,7 @@ class KIcons {
static const IconData welcome = MaterialCommunityIcons.message_text;
static const IconData dashboard = MaterialCommunityIcons.view_dashboard;
+ static const IconData queryLog = MaterialCommunityIcons.file_document;
static const IconData clients = MaterialCommunityIcons.laptop_windows;
static const IconData domains =
MaterialCommunityIcons.checkbox_multiple_blank_circle_outline;
diff --git a/lib/features/pihole_api/blocs/query_log_bloc.dart b/lib/features/pihole_api/blocs/query_log_bloc.dart
new file mode 100644
index 00000000..c0b0cff1
--- /dev/null
+++ b/lib/features/pihole_api/blocs/query_log_bloc.dart
@@ -0,0 +1,75 @@
+import 'package:bloc/bloc.dart';
+import 'package:dartz/dartz.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutterhole/core/models/failures.dart';
+import 'package:flutterhole/dependency_injection.dart';
+import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/pihole_api/data/repositories/api_repository.dart';
+import 'package:flutterhole/features/settings/data/models/pihole_settings.dart';
+import 'package:flutterhole/features/settings/data/repositories/settings_repository.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'query_log_bloc.freezed.dart';
+
+@freezed
+abstract class QueryLogState with _$QueryLogState {
+ const factory QueryLogState.initial() = QueryLogStateInitial;
+
+ const factory QueryLogState.loading() = QueryLogStateLoading;
+
+ const factory QueryLogState.failure(Failure failure) = QueryLogStateFailure;
+
+ const factory QueryLogState.success(List queries) =
+ QueryLogStateSuccess;
+}
+
+@freezed
+abstract class QueryLogEvent with _$QueryLogEvent {
+ const factory QueryLogEvent.fetchAll() = QueryLogEventFetchAll;
+
+ const factory QueryLogEvent.fetchSome(int maxResults) =
+ QueryLogEventFetchSome;
+}
+
+class QueryLogBloc extends Bloc {
+ QueryLogBloc([
+ ApiRepository apiRepository,
+ SettingsRepository settingsRepository,
+ ]) : _apiRepository = apiRepository ?? getIt(),
+ _settingsRepository = settingsRepository ?? getIt();
+
+ final ApiRepository _apiRepository;
+ final SettingsRepository _settingsRepository;
+
+ @override
+ QueryLogState get initialState => QueryLogState.initial();
+
+ Stream _fetch([int maxResults]) async* {
+ yield QueryLogStateLoading();
+
+ final Either active =
+ await _settingsRepository.fetchActivePiholeSettings();
+
+ yield* active.fold(
+ (Failure failure) async* {
+ yield QueryLogState.failure(failure);
+ },
+ (PiholeSettings settings) async* {
+ final Either> result =
+ await _apiRepository.fetchManyQueryData(settings, maxResults);
+
+ yield* result.fold((Failure failure) async* {
+ yield QueryLogState.failure(failure);
+ }, (queries) async* {
+ yield QueryLogState.success(queries);
+ });
+ },
+ );
+ }
+
+ @override
+ Stream mapEventToState(QueryLogEvent event) => event.when(
+ fetchAll: () => _fetch(),
+ fetchSome: (maxResults) => _fetch(maxResults),
+ );
+}
diff --git a/lib/features/pihole_api/data/datasources/api_data_source.dart b/lib/features/pihole_api/data/datasources/api_data_source.dart
index f2bc3c92..80ee62a0 100644
--- a/lib/features/pihole_api/data/datasources/api_data_source.dart
+++ b/lib/features/pihole_api/data/datasources/api_data_source.dart
@@ -34,9 +34,12 @@ abstract class ApiDataSource {
Future fetchVersions(PiholeSettings settings);
- Future fetchQueryDataForClient(PiholeSettings settings,
- PiClient client);
+ Future fetchQueryDataForClient(
+ PiholeSettings settings, PiClient client);
- Future fetchQueryDataForDomain(PiholeSettings settings,
- String domain);
+ Future fetchQueryDataForDomain(
+ PiholeSettings settings, String domain);
+
+ Future fetchManyQueryData(PiholeSettings settings,
+ [int maxResults]);
}
diff --git a/lib/features/pihole_api/data/datasources/api_data_source_dio.dart b/lib/features/pihole_api/data/datasources/api_data_source_dio.dart
index d89885c7..1b36b731 100644
--- a/lib/features/pihole_api/data/datasources/api_data_source_dio.dart
+++ b/lib/features/pihole_api/data/datasources/api_data_source_dio.dart
@@ -231,8 +231,10 @@ class ApiDataSourceDio implements ApiDataSource {
}
@override
- Future fetchQueryDataForClient(PiholeSettings settings,
- PiClient client,) async {
+ Future fetchQueryDataForClient(
+ PiholeSettings settings,
+ PiClient client,
+ ) async {
final Map json =
await _getSecure(settings, queryParameters: {
'getAllQueries': '',
@@ -255,4 +257,15 @@ class ApiDataSourceDio implements ApiDataSource {
return ManyQueryData.fromJson(json);
}
+
+ @override
+ Future fetchManyQueryData(PiholeSettings settings,
+ [int maxResults]) async {
+ final Map json =
+ await _getSecure(settings, queryParameters: {
+ 'getAllQueries': '${maxResults ?? ''}',
+ });
+
+ return ManyQueryData.fromJson(json);
+ }
}
diff --git a/lib/features/pihole_api/data/repositories/api_repository.dart b/lib/features/pihole_api/data/repositories/api_repository.dart
index 94a4764c..44d9ec29 100644
--- a/lib/features/pihole_api/data/repositories/api_repository.dart
+++ b/lib/features/pihole_api/data/repositories/api_repository.dart
@@ -32,4 +32,8 @@ abstract class ApiRepository {
Future>> fetchQueriesForDomain(
PiholeSettings settings, String domain);
+
+ Future>> fetchManyQueryData(
+ PiholeSettings settings,
+ [int maxResults]);
}
diff --git a/lib/features/pihole_api/data/repositories/api_repository_impl.dart b/lib/features/pihole_api/data/repositories/api_repository_impl.dart
index fa256089..d1c22243 100644
--- a/lib/features/pihole_api/data/repositories/api_repository_impl.dart
+++ b/lib/features/pihole_api/data/repositories/api_repository_impl.dart
@@ -98,7 +98,7 @@ class ApiRepositoryImpl implements ApiRepository {
PiholeSettings settings, PiClient client) async {
try {
final ManyQueryData result =
- await _apiDataSource.fetchQueryDataForClient(settings, client);
+ await _apiDataSource.fetchQueryDataForClient(settings, client);
return Right(result.data.reversed.toList());
} on PiException catch (e) {
return Left(Failure('fetchQueriesForClient failed', e));
@@ -116,4 +116,17 @@ class ApiRepositoryImpl implements ApiRepository {
return Left(Failure('fetchQueriesForDomain failed', e));
}
}
+
+ @override
+ Future>> fetchManyQueryData(
+ PiholeSettings settings,
+ [int maxResults]) async {
+ try {
+ final ManyQueryData result =
+ await _apiDataSource.fetchManyQueryData(settings, maxResults);
+ return Right(result.data.reversed.toList());
+ } on PiException catch (e) {
+ return Left(Failure('fetchManyQueryData failed', e));
+ }
+ }
}
diff --git a/lib/features/pihole_api/presentation/pages/query_log_page.dart b/lib/features/pihole_api/presentation/pages/query_log_page.dart
new file mode 100644
index 00000000..35ec1c95
--- /dev/null
+++ b/lib/features/pihole_api/presentation/pages/query_log_page.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutterhole/dependency_injection.dart';
+import 'package:flutterhole/features/pihole_api/blocs/query_log_bloc.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/query_log_page_overflow_refresher.dart';
+import 'package:flutterhole/features/routing/presentation/widgets/default_drawer.dart';
+import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
+import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:flutterhole/widgets/layout/loading_indicators.dart';
+import 'package:intl/intl.dart';
+
+final _numberFormat = NumberFormat();
+
+class _PopupMenu extends StatelessWidget {
+ PopupMenuItem _buildPopupMenuItem(int value) {
+ return CheckedPopupMenuItem(
+ child: Text('${_numberFormat.format(value)}'),
+ value: value,
+ checked: value == getIt().queryLogMaxResults,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return PopupMenuButton(
+ tooltip: 'Set max results',
+// initialValue: getIt().queryLogMaxResults,
+ onSelected: (int value) async {
+ getIt().setQueryLogMaxResults(value);
+
+ // Force a delay to allow the PopUpMenu to close.
+ // If we don't, the animation hangs until the Bloc returns.
+ // TODO not sure what a better solution is, but I assume there is one.
+ await Future.delayed(Duration(milliseconds: 300));
+
+ BlocProvider.of(context)
+ .add(QueryLogEvent.fetchSome(value));
+ },
+
+ itemBuilder: (BuildContext context) => >[
+ PopupMenuItem(
+ child: Text('Max results'),
+ enabled: false,
+ ),
+ PopupMenuDivider(),
+ _buildPopupMenuItem(10),
+ _buildPopupMenuItem(100),
+ _buildPopupMenuItem(1000),
+ _buildPopupMenuItem(10000),
+ ],
+ );
+ }
+}
+
+class QueryLogPage extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) =>
+ QueryLogBloc()
+ ..add(QueryLogEvent.fetchSome(
+ getIt().queryLogMaxResults)),
+ child: PiholeThemeBuilder(
+ child: Scaffold(
+ drawer: DefaultDrawer(),
+ appBar: AppBar(
+ title: Text('Query log'),
+ elevation: 0.0,
+ actions: [
+ _PopupMenu(),
+ ],
+ ),
+ body: QueryLogPageOverflowRefresher(
+ child: BlocBuilder(
+ builder: (BuildContext context, QueryLogState state) {
+ return state.maybeWhen(
+ success: (queries) {
+ return ManyQueryTilesBuilder(queries: queries);
+ },
+ initial: () => Container(),
+ orElse: () {
+ return CenteredLoadingIndicator();
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart b/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart
index e903e006..0bceb6da 100644
--- a/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart
+++ b/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart
@@ -7,15 +7,18 @@ class ManyQueryTilesBuilder extends StatelessWidget {
const ManyQueryTilesBuilder({
Key key,
@required this.queries,
+ this.shrinkWrap = false,
}) : super(key: key);
final List queries;
+ final bool shrinkWrap;
@override
Widget build(BuildContext context) {
return AnimateOnBuild(
child: Scrollbar(
child: ListView.builder(
+ shrinkWrap: shrinkWrap,
itemCount: queries.length,
itemBuilder: (context, index) {
final QueryData query = queries.elementAt(index);
diff --git a/lib/features/pihole_api/presentation/widgets/query_log_page_overflow_refresher.dart b/lib/features/pihole_api/presentation/widgets/query_log_page_overflow_refresher.dart
new file mode 100644
index 00000000..1ef7907d
--- /dev/null
+++ b/lib/features/pihole_api/presentation/widgets/query_log_page_overflow_refresher.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutterhole/dependency_injection.dart';
+import 'package:flutterhole/features/pihole_api/blocs/query_log_bloc.dart';
+import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:pull_to_refresh/pull_to_refresh.dart';
+
+typedef void OnRefreshCallback(BuildContext context);
+
+class QueryLogPageOverflowRefresher extends StatefulWidget {
+ const QueryLogPageOverflowRefresher({
+ Key key,
+ @required this.child,
+ }) : super(key: key);
+
+ final Widget child;
+
+ @override
+ _QueryLogPageOverflowRefresherState createState() =>
+ _QueryLogPageOverflowRefresherState();
+}
+
+class _QueryLogPageOverflowRefresherState
+ extends State {
+ final RefreshController _refreshController = RefreshController();
+
+ void _onRefresh() {
+ BlocProvider.of(context).add(
+ QueryLogEvent.fetchSome(getIt().queryLogMaxResults));
+ }
+
+ @override
+ void dispose() {
+ _refreshController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return RefreshConfiguration(
+ headerBuilder: () => WaterDropMaterialHeader(
+ semanticsLabel: 'Refresh data',
+ color: Theme.of(context).colorScheme.onBackground,
+ ),
+ enableBallisticLoad: true,
+ child: BlocListener(
+ listener: (BuildContext context, QueryLogState state) {
+ if (state is QueryLogStateSuccess) {
+ _refreshController?.refreshCompleted();
+ }
+ },
+ child: SmartRefresher(
+ enablePullDown: true,
+ controller: _refreshController,
+ onRefresh: _onRefresh,
+ child: widget.child,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart b/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart
index 6079e1cc..55faa893 100644
--- a/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart
+++ b/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart
@@ -115,8 +115,7 @@ class SingleQueryDataTile extends StatelessWidget {
final QueryData query;
String get _timeStamp =>
- '${query.timestamp.formattedDate}\t${query.timestamp
- .formattedTime} (${query.timestamp.fromNow})';
+ '${query.timestamp.formattedDate}\t${query.timestamp.formattedTime} (${query.timestamp.fromNow})';
Icon _buildQueryStatusIcon() {
return Icon(
@@ -175,8 +174,7 @@ class SingleQueryDataTile extends StatelessWidget {
return ListTile(
dense: true,
title: Text('${type.toFullString}'),
- subtitle:
- Text('${type.toDescription}'),
+ subtitle: Text('${type.toDescription}'),
);
}),
OpenUrlTile(
diff --git a/lib/features/routing/presentation/widgets/default_drawer.dart b/lib/features/routing/presentation/widgets/default_drawer.dart
index f3ec8624..7d4edd39 100644
--- a/lib/features/routing/presentation/widgets/default_drawer.dart
+++ b/lib/features/routing/presentation/widgets/default_drawer.dart
@@ -25,6 +25,11 @@ class DefaultDrawer extends StatelessWidget {
title: Text('Dashboard'),
icon: Icon(KIcons.dashboard),
),
+ DrawerTile(
+ routeName: RouterService.queryLog,
+ title: Text('Query log'),
+ icon: Icon(KIcons.queryLog),
+ ),
DrawerTile(
routeName: RouterService.settings,
title: Text('Settings'),
diff --git a/lib/features/routing/services/router_service.dart b/lib/features/routing/services/router_service.dart
index b01f3914..0f2ebf4e 100644
--- a/lib/features/routing/services/router_service.dart
+++ b/lib/features/routing/services/router_service.dart
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
abstract class RouterService {
static const String home = '/';
+ static const String queryLog = '/query-log';
static const String settings = '/settings';
static const String allPiholes = '/all-piholes';
static const String userPreferences = '/user-preferences';
diff --git a/lib/features/routing/services/router_service_sailor.dart b/lib/features/routing/services/router_service_sailor.dart
index 881c5621..fe6de953 100644
--- a/lib/features/routing/services/router_service_sailor.dart
+++ b/lib/features/routing/services/router_service_sailor.dart
@@ -3,6 +3,7 @@ import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/navigator.dart';
import 'package:flutterhole/dependency_injection.dart';
import 'package:flutterhole/features/home/presentation/pages/home_page.dart';
+import 'package:flutterhole/features/pihole_api/presentation/pages/query_log_page.dart';
import 'package:flutterhole/features/routing/presentation/pages/about_page.dart';
import 'package:flutterhole/features/routing/services/router_service.dart';
import 'package:flutterhole/features/settings/presentation/pages/all_pihole_settings_page.dart';
@@ -32,6 +33,11 @@ class RouterServiceSailor implements RouterService {
builder: (context, args, params) {
return HomePage();
}),
+ SailorRoute(
+ name: RouterService.queryLog,
+ builder: (context, args, params) {
+ return QueryLogPage();
+ }),
SailorRoute(
name: RouterService.settings,
builder: (context, args, params) {
diff --git a/lib/features/settings/presentation/pages/user_preferences_page.dart b/lib/features/settings/presentation/pages/user_preferences_page.dart
index 1faf445f..326a082a 100644
--- a/lib/features/settings/presentation/pages/user_preferences_page.dart
+++ b/lib/features/settings/presentation/pages/user_preferences_page.dart
@@ -1,43 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutterhole/constants.dart';
-import 'package:flutterhole/core/convert.dart';
-import 'package:flutterhole/dependency_injection.dart';
import 'package:flutterhole/features/settings/presentation/notifiers/theme_mode_notifier.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
-import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:flutterhole/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart';
+import 'package:flutterhole/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart';
import 'package:flutterhole/widgets/layout/dialogs.dart';
import 'package:flutterhole/widgets/layout/list_title.dart';
-import 'package:flutterhole/widgets/layout/snackbars.dart';
-import 'package:preferences/preferences.dart';
import 'package:provider/provider.dart';
-enum _PopupOption {
- reset,
-}
-
-class _PopupMenu extends StatelessWidget {
- @override
- Widget build(BuildContext context) {
- return PopupMenuButton<_PopupOption>(
- onSelected: (option) async {
- switch (option) {
- case _PopupOption.reset:
- await getIt().reset();
- showInfoSnackBar(context, 'Preferences reset',
- duration: Duration(seconds: 2));
- break;
- }
- },
- itemBuilder: (BuildContext context) => >[
- const PopupMenuItem(
- child: Text('Reset my preferences'),
- value: _PopupOption.reset,
- ),
- ],
- );
- }
-}
-
class UserPreferencesPage extends StatefulWidget {
@override
_UserPreferencesPageState createState() => _UserPreferencesPageState();
@@ -47,9 +17,11 @@ class _UserPreferencesPageState extends State {
@override
Widget build(BuildContext context) {
return Consumer(
- builder: (BuildContext context,
- ThemeModeNotifier notifier,
- _,) {
+ builder: (
+ BuildContext context,
+ ThemeModeNotifier notifier,
+ _,
+ ) {
return PiholeThemeBuilder(
child: Scaffold(
appBar: AppBar(
@@ -62,36 +34,9 @@ class _UserPreferencesPageState extends State {
body: ListView(
children: [
ListTitle('Customization'),
- RadioPreference(
- '${ThemeModeEnumMap[ThemeMode.system].capitalize} theme',
- '${ThemeModeEnumMap[ThemeMode.system]}',
- KPrefs.themeMode,
- isDefault: true,
- onSelect: () => notifier.update(),
- leading: Icon(KIcons.themeSystem),
- ),
- RadioPreference(
- '${ThemeModeEnumMap[ThemeMode.light].capitalize} theme',
- '${ThemeModeEnumMap[ThemeMode.light]}',
- KPrefs.themeMode,
- onSelect: () => notifier.update(),
- leading: Icon(KIcons.themeLight),
- ),
- RadioPreference(
- '${ThemeModeEnumMap[ThemeMode.dark].capitalize} theme',
- '${ThemeModeEnumMap[ThemeMode.dark]}',
- KPrefs.themeMode,
- onSelect: () => notifier.update(),
- leading: Icon(KIcons.themeDark),
- ),
+ ThemeRadioPreferences(),
ListTitle('Data'),
- SwitchPreference(
- 'Use numbers API',
- KPrefs.useNumbersApi,
- defaultVal: true,
- desc:
- 'If enabled, the dashboard will fetch number trivia from the Numbers API.',
- ),
+ UseNumbersApiSwitchPreference(),
ListTitle('Misc'),
ListTile(
leading: Icon(KIcons.welcome),
diff --git a/lib/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart b/lib/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart
new file mode 100644
index 00000000..e40c7d17
--- /dev/null
+++ b/lib/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:flutterhole/constants.dart';
+import 'package:flutterhole/core/convert.dart';
+import 'package:flutterhole/features/settings/presentation/notifiers/theme_mode_notifier.dart';
+import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:preferences/radio_preference.dart';
+import 'package:provider/provider.dart';
+
+class ThemeRadioPreferences extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(builder: (
+ BuildContext context,
+ ThemeModeNotifier notifier,
+ _,
+ ) {
+ return Column(
+ children: [
+ RadioPreference(
+ '${ThemeModeEnumMap[ThemeMode.system].capitalize} theme',
+ '${ThemeModeEnumMap[ThemeMode.system]}',
+ KPrefs.themeMode,
+ isDefault: true,
+ onSelect: () => notifier.update(),
+ leading: Icon(KIcons.themeSystem),
+ ),
+ RadioPreference(
+ '${ThemeModeEnumMap[ThemeMode.light].capitalize} theme',
+ '${ThemeModeEnumMap[ThemeMode.light]}',
+ KPrefs.themeMode,
+ onSelect: () => notifier.update(),
+ leading: Icon(KIcons.themeLight),
+ ),
+ RadioPreference(
+ '${ThemeModeEnumMap[ThemeMode.dark].capitalize} theme',
+ '${ThemeModeEnumMap[ThemeMode.dark]}',
+ KPrefs.themeMode,
+ onSelect: () => notifier.update(),
+ leading: Icon(KIcons.themeDark),
+ ),
+ ],
+ );
+ });
+ }
+}
diff --git a/lib/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart b/lib/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart
new file mode 100644
index 00000000..015fe7db
--- /dev/null
+++ b/lib/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart
@@ -0,0 +1,20 @@
+import 'package:flutter/material.dart';
+import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:preferences/switch_preference.dart';
+
+class UseNumbersApiSwitchPreference extends StatelessWidget {
+ const UseNumbersApiSwitchPreference({
+ Key key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ return SwitchPreference(
+ 'Use numbers API',
+ KPrefs.useNumbersApi,
+ defaultVal: true,
+ desc:
+ 'If enabled, the dashboard will fetch number trivia from the Numbers API.',
+ );
+ }
+}
diff --git a/lib/features/settings/services/preference_service.dart b/lib/features/settings/services/preference_service.dart
index 1a4dee50..d55c73c6 100644
--- a/lib/features/settings/services/preference_service.dart
+++ b/lib/features/settings/services/preference_service.dart
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart' show ThemeMode;
+/// Constant preference keys for map-like storage implementations.
class KPrefs {
KPrefs._();
static const String isFirstUse = 'isFirstUse';
static const String useNumbersApi = 'useNumbersApi';
static const String themeMode = 'themeMode';
+ static const String queryLogMaxResults = 'queryLogMaxResults';
}
const ThemeModeEnumMap = {
@@ -28,4 +30,8 @@ abstract class PreferenceService {
bool get useNumbersApi;
ThemeMode get themeMode;
+
+ int get queryLogMaxResults;
+
+ Future setQueryLogMaxResults(int maxResults);
}
diff --git a/lib/features/settings/services/preference_service_impl.dart b/lib/features/settings/services/preference_service_impl.dart
index df374570..5211afdc 100644
--- a/lib/features/settings/services/preference_service_impl.dart
+++ b/lib/features/settings/services/preference_service_impl.dart
@@ -81,4 +81,11 @@ class PrServiceImpl implements PreferenceService {
final String value = _get(KPrefs.themeMode) ?? 'system';
return ThemeModeMapEnum[value];
}
+
+ @override
+ int get queryLogMaxResults => _get(KPrefs.queryLogMaxResults) ?? 100;
+
+ @override
+ Future setQueryLogMaxResults(int maxResults) async =>
+ _set(KPrefs.queryLogMaxResults, maxResults);
}
diff --git a/test/features/pihole_api/blocs/query_log_bloc_test.dart b/test/features/pihole_api/blocs/query_log_bloc_test.dart
new file mode 100644
index 00000000..ba63db58
--- /dev/null
+++ b/test/features/pihole_api/blocs/query_log_bloc_test.dart
@@ -0,0 +1,126 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:dartz/dartz.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutterhole/core/models/failures.dart';
+import 'package:flutterhole/features/pihole_api/blocs/query_log_bloc.dart';
+import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/pihole_api/data/repositories/api_repository.dart';
+import 'package:flutterhole/features/settings/data/models/pihole_settings.dart';
+import 'package:flutterhole/features/settings/data/repositories/settings_repository.dart';
+import 'package:mockito/mockito.dart';
+
+import '../../../test_dependency_injection.dart';
+
+class MockApiRepository extends Mock implements ApiRepository {}
+
+class MockSettingsRepository extends Mock implements SettingsRepository {}
+
+void main() {
+ setUpAllForTest();
+
+ PiholeSettings settings;
+ ApiRepository mockApiRepository;
+ SettingsRepository mockSettingsRepository;
+ QueryLogBloc bloc;
+
+ setUp(() {
+ settings = PiholeSettings(title: 'First', apiToken: 'token');
+ mockApiRepository = MockApiRepository();
+ mockSettingsRepository = MockSettingsRepository();
+ bloc = QueryLogBloc(mockApiRepository, mockSettingsRepository);
+ });
+
+ tearDown(() {
+ bloc.close();
+ });
+
+ blocTest(
+ 'Initially emits QueryLogStateInitial',
+ build: () async => bloc,
+ skip: 0,
+ expect: [QueryLogStateInitial()],
+ );
+
+ blocTest(
+ 'Emits [] when nothing is added',
+ build: () async => bloc,
+ expect: [],
+ );
+
+ group('$QueryLogEventFetchAll', () {
+ final List queries = [
+ QueryData(clientName: 'first'),
+ QueryData(clientName: 'second'),
+ QueryData(clientName: 'last'),
+ ];
+ final tFailure = Failure();
+
+ blocTest(
+ 'Emits [$QueryLogStateLoading, $QueryLogStateSuccess] when $QueryLogEventFetchAll succeeds',
+ build: () async {
+ when(mockSettingsRepository.fetchActivePiholeSettings())
+ .thenAnswer((_) async => Right(settings));
+ when(mockApiRepository.fetchManyQueryData(settings))
+ .thenAnswer((_) async => Right(queries));
+
+ return bloc;
+ },
+ act: (QueryLogBloc bloc) async => bloc.add(QueryLogEvent.fetchAll()),
+ expect: [QueryLogStateLoading(), QueryLogStateSuccess(queries)],
+ );
+
+ blocTest(
+ 'Emits [$QueryLogStateLoading, $QueryLogStateFailure] when $QueryLogEventFetchAll fails',
+ build: () async {
+ when(mockSettingsRepository.fetchActivePiholeSettings())
+ .thenAnswer((_) async => Right(settings));
+ when(mockApiRepository.fetchManyQueryData(settings))
+ .thenAnswer((_) async => Left(tFailure));
+
+ return bloc;
+ },
+ act: (QueryLogBloc bloc) async => bloc.add(QueryLogEvent.fetchAll()),
+ expect: [QueryLogStateLoading(), QueryLogStateFailure(tFailure)],
+ );
+ });
+
+ group('$QueryLogEventFetchSome', () {
+ final List queries = [
+ QueryData(clientName: 'first'),
+ QueryData(clientName: 'second'),
+ QueryData(clientName: 'last'),
+ ];
+ final tFailure = Failure();
+ final maxResults = 123;
+
+ blocTest(
+ 'Emits [$QueryLogStateLoading, $QueryLogStateSuccess] when $QueryLogEventFetchSome succeeds',
+ build: () async {
+ when(mockSettingsRepository.fetchActivePiholeSettings())
+ .thenAnswer((_) async => Right(settings));
+ when(mockApiRepository.fetchManyQueryData(settings, maxResults))
+ .thenAnswer((_) async => Right(queries));
+
+ return bloc;
+ },
+ act: (QueryLogBloc bloc) async =>
+ bloc.add(QueryLogEvent.fetchSome(maxResults)),
+ expect: [QueryLogStateLoading(), QueryLogStateSuccess(queries)],
+ );
+
+ blocTest(
+ 'Emits [$QueryLogStateLoading, $QueryLogStateFailure] when $QueryLogEventFetchSome fails',
+ build: () async {
+ when(mockSettingsRepository.fetchActivePiholeSettings())
+ .thenAnswer((_) async => Right(settings));
+ when(mockApiRepository.fetchManyQueryData(settings, maxResults))
+ .thenAnswer((_) async => Left(tFailure));
+
+ return bloc;
+ },
+ act: (QueryLogBloc bloc) async =>
+ bloc.add(QueryLogEvent.fetchSome(maxResults)),
+ expect: [QueryLogStateLoading(), QueryLogStateFailure(tFailure)],
+ );
+ });
+}
diff --git a/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart b/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart
index 62a6c68a..b461e4e1 100644
--- a/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart
+++ b/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart
@@ -288,7 +288,7 @@ void main() async {
test(
'should return $PiVersions on successful fetchPiVersions',
- () async {
+ () async {
// arrange
piholeSettings = piholeSettings.copyWith(apiToken: 'token');
final json = stubFixtureResponse('get_versions.json', 200);
@@ -346,4 +346,32 @@ void main() async {
expect(result, equals(ManyQueryData.fromJson(json)));
},
);
+
+ test(
+ 'should return $ManyQueryData on successful fetchManyQueryData without maxResult',
+ () async {
+ // arrange
+ piholeSettings = piholeSettings.copyWith(apiToken: 'token');
+ final json = stubFixtureResponse('get_all_queries_10.json', 200);
+ // act
+ final ManyQueryData result =
+ await apiDataSourceDio.fetchManyQueryData(piholeSettings);
+ // assert
+ expect(result, equals(ManyQueryData.fromJson(json)));
+ },
+ );
+
+ test(
+ 'should return $ManyQueryData on successful fetchManyQueryData with maxResult',
+ () async {
+ // arrange
+ piholeSettings = piholeSettings.copyWith(apiToken: 'token');
+ final json = stubFixtureResponse('get_all_queries_10.json', 200);
+ // act
+ final ManyQueryData result =
+ await apiDataSourceDio.fetchManyQueryData(piholeSettings, 123);
+ // assert
+ expect(result, equals(ManyQueryData.fromJson(json)));
+ },
+ );
}
diff --git a/test/features/pihole_api/data/repositories/api_repository_impl_test.dart b/test/features/pihole_api/data/repositories/api_repository_impl_test.dart
index 82859062..ba122a15 100644
--- a/test/features/pihole_api/data/repositories/api_repository_impl_test.dart
+++ b/test/features/pihole_api/data/repositories/api_repository_impl_test.dart
@@ -5,6 +5,7 @@ import 'package:flutterhole/core/models/failures.dart';
import 'package:flutterhole/features/pihole_api/data/datasources/api_data_source.dart';
import 'package:flutterhole/features/pihole_api/data/models/dns_query_type.dart';
import 'package:flutterhole/features/pihole_api/data/models/forward_destinations.dart';
+import 'package:flutterhole/features/pihole_api/data/models/many_query_data.dart';
import 'package:flutterhole/features/pihole_api/data/models/over_time_data.dart';
import 'package:flutterhole/features/pihole_api/data/models/pi_client.dart';
import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
@@ -231,24 +232,32 @@ void main() async {
group('fetchQueryDataForClient', () {
final PiClient client = PiClient(title: 'test.org');
-// test(
-// 'should return reversed List on successful fetchQueriesForClient',
-// () async {
-// // arrange
-// final ManyQueryData manyQueryData = ManyQueryData(
-// data: [QueryData(clientName: client.title)],
-// );
-//
-// when(mockApiDataSource.fetchQueryDataForClient(piholeSettings, client))
-// .thenAnswer((_) async => manyQueryData);
-// // act
-// final Either> result =
-// await apiRepository.fetchQueriesForClient(piholeSettings, client);
-// // assert
-// expect(result,
-// equals(Right>(manyQueryData.data.reversed.toList())));
-// },
-// );
+ test(
+ 'should return reversed List on successful fetchQueriesForClient',
+ () async {
+ // arrange
+ final ManyQueryData manyQueryData = ManyQueryData(
+ data: [
+ QueryData(clientName: 'first'),
+ QueryData(clientName: 'second'),
+ QueryData(clientName: 'third'),
+ ],
+ );
+
+ when(mockApiDataSource.fetchQueryDataForClient(piholeSettings, client))
+ .thenAnswer((_) async => manyQueryData);
+ // act
+ final Either> result =
+ await apiRepository.fetchQueriesForClient(piholeSettings, client);
+ // assert
+ result.fold(
+ (failure) => fail('result should be Right'),
+ (list) {
+ expect(list, equals(manyQueryData.data.reversed.toList()));
+ },
+ );
+ },
+ );
test(
'should return $Failure on failed fetchQueriesForClient',
@@ -267,25 +276,29 @@ void main() async {
);
});
- group('fetchQueryDataForDomain', () {
+ group('fetchQueriesForDomain', () {
final String domain = 'test.org';
-// test(
-// 'should return reversed List on successful fetchQueriesForDomain',
-// () async {
-// // arrange
-// final ManyQueryData manyQueryData = ManyQueryData(
-// data: [QueryData(domain: domain)],
-// );
-// when(mockApiDataSource.fetchQueryDataForDomain(piholeSettings, domain))
-// .thenAnswer((_) async => manyQueryData);
-// // act
-// final Either> result =
-// await apiRepository.fetchQueriesForDomain(piholeSettings, domain);
-// // assert
-// expect(result,
-// equals(Right>(manyQueryData.data.reversed.toList())));
-// },
-// );
+ test(
+ 'should return reversed List on successful fetchQueriesForDomain',
+ () async {
+ // arrange
+ final ManyQueryData manyQueryData = ManyQueryData(
+ data: [QueryData(domain: domain)],
+ );
+ when(mockApiDataSource.fetchQueryDataForDomain(piholeSettings, domain))
+ .thenAnswer((_) async => manyQueryData);
+ // act
+ final Either> result =
+ await apiRepository.fetchQueriesForDomain(piholeSettings, domain);
+ // assert
+ result.fold(
+ (failure) => fail('result should be Right'),
+ (list) {
+ expect(list, equals(manyQueryData.data.reversed.toList()));
+ },
+ );
+ },
+ );
test(
'should return $Failure on failed fetchQueriesForDomain',
@@ -303,4 +316,75 @@ void main() async {
},
);
});
+
+ group('fetchManyQueryData', () {
+ test(
+ 'should return reversed List on successful fetchManyQueryData without maxResults',
+ () async {
+ // arrange
+ final ManyQueryData manyQueryData = ManyQueryData(
+ data: [
+ QueryData(clientName: 'first'),
+ QueryData(clientName: 'second'),
+ QueryData(clientName: 'third'),
+ ],
+ );
+ when(mockApiDataSource.fetchManyQueryData(piholeSettings))
+ .thenAnswer((_) async => manyQueryData);
+ // act
+ final Either> result =
+ await apiRepository.fetchManyQueryData(piholeSettings);
+ // assert
+ result.fold(
+ (failure) => fail('result should be Right'),
+ (list) {
+ expect(list, equals(manyQueryData.data.reversed.toList()));
+ },
+ );
+ },
+ );
+
+ test(
+ 'should return reversed List on successful fetchManyQueryData with maxResults',
+ () async {
+ // arrange
+ final int maxResults = 123;
+ final ManyQueryData manyQueryData = ManyQueryData(
+ data: [
+ QueryData(clientName: 'first'),
+ QueryData(clientName: 'second'),
+ QueryData(clientName: 'third'),
+ ],
+ );
+ when(mockApiDataSource.fetchManyQueryData(piholeSettings, maxResults))
+ .thenAnswer((_) async => manyQueryData);
+ // act
+ final Either> result =
+ await apiRepository.fetchManyQueryData(piholeSettings, maxResults);
+ // assert
+ result.fold(
+ (failure) => fail('result should be Right'),
+ (list) {
+ expect(list, equals(manyQueryData.data.reversed.toList()));
+ },
+ );
+ },
+ );
+
+ test(
+ 'should return $Failure on failed fetchManyQueryData',
+ () async {
+ // arrange
+ final tError = PiException.emptyResponse();
+ when(mockApiDataSource.fetchManyQueryData(piholeSettings))
+ .thenThrow(tError);
+ // act
+ final Either> result =
+ await apiRepository.fetchManyQueryData(piholeSettings);
+ // assert
+ expect(
+ result, equals(Left(Failure('fetchManyQueryData failed', tError))));
+ },
+ );
+ });
}
From b80f46f96fa80f2d2f6635bd30373c8b8a6e64e5 Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Tue, 19 May 2020 11:46:43 +0200
Subject: [PATCH 04/11] Searchable & refreshable query log/client/domain pages
---
lib/constants.dart | 2 +
.../pihole_api/data/models/query_data.dart | 6 +-
.../notifiers/queries_search_notifier.dart | 27 +++
.../presentation/pages/query_log_page.dart | 82 +++++---
.../pages/single_client_page.dart | 65 +++---
.../pages/single_domain_page.dart | 69 ++++---
.../widgets/many_query_tiles_builder.dart | 31 ---
.../widgets/queries_search_app_bar.dart | 115 +++++++++++
.../widgets/queries_search_list_builder.dart | 50 +++++
...single_client_page_overflow_refresher.dart | 62 ++++++
...single_domain_page_overflow_refresher.dart | 61 ++++++
.../widgets/single_query_data_tile.dart | 186 +++++++++---------
.../widgets/pihole_theme_builder.dart | 2 +-
release_notes.txt | 4 +
14 files changed, 555 insertions(+), 207 deletions(-)
create mode 100644 lib/features/pihole_api/presentation/notifiers/queries_search_notifier.dart
delete mode 100644 lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart
create mode 100644 lib/features/pihole_api/presentation/widgets/queries_search_app_bar.dart
create mode 100644 lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart
create mode 100644 lib/features/pihole_api/presentation/widgets/single_client_page_overflow_refresher.dart
create mode 100644 lib/features/pihole_api/presentation/widgets/single_domain_page_overflow_refresher.dart
diff --git a/lib/constants.dart b/lib/constants.dart
index d3764d07..ab609587 100644
--- a/lib/constants.dart
+++ b/lib/constants.dart
@@ -40,7 +40,9 @@ class KIcons {
static const IconData log = AntDesign.codesquare;
static const IconData open = MaterialIcons.keyboard_arrow_right;
+ static const IconData search = MaterialIcons.search;
static const IconData close = Icons.close;
+ static const IconData back = Icons.arrow_back;
static const IconData delete = MaterialCommunityIcons.delete_circle;
static const IconData openInBrowser = MaterialIcons.open_in_browser;
static const IconData copy = Icons.content_copy;
diff --git a/lib/features/pihole_api/data/models/query_data.dart b/lib/features/pihole_api/data/models/query_data.dart
index 12d808d0..a376f7e2 100644
--- a/lib/features/pihole_api/data/models/query_data.dart
+++ b/lib/features/pihole_api/data/models/query_data.dart
@@ -106,8 +106,8 @@ abstract class QueryData extends ListModel implements _$QueryData {
'${QueryStatus.values.indexOf(queryStatus)}',
'${DnsSecStatus.values.indexOf(dnsSecStatus)}',
'$replyTextIndex',
- '${(replyDuration.inMicroseconds / 100).round()}',
- replyText,
- someString,
+ '${(replyDuration.inMicroseconds / 100).round()}',
+ replyText,
+ someString,
];
}
diff --git a/lib/features/pihole_api/presentation/notifiers/queries_search_notifier.dart b/lib/features/pihole_api/presentation/notifiers/queries_search_notifier.dart
new file mode 100644
index 00000000..33f2cfdb
--- /dev/null
+++ b/lib/features/pihole_api/presentation/notifiers/queries_search_notifier.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+
+class QueriesSearchNotifier extends ChangeNotifier {
+ bool _isSearching = false;
+ String _searchQuery = '';
+
+ bool get isSearching => _isSearching;
+
+ String get searchQuery => _searchQuery;
+
+ set searchQuery(String value) {
+ _searchQuery = value.toLowerCase();
+ notifyListeners();
+ }
+
+ void startSearching() {
+ _isSearching = true;
+ _searchQuery = '';
+ notifyListeners();
+ }
+
+ void stopSearching() {
+ _isSearching = false;
+ _searchQuery = '';
+ notifyListeners();
+ }
+}
diff --git a/lib/features/pihole_api/presentation/pages/query_log_page.dart b/lib/features/pihole_api/presentation/pages/query_log_page.dart
index 35ec1c95..f24b222c 100644
--- a/lib/features/pihole_api/presentation/pages/query_log_page.dart
+++ b/lib/features/pihole_api/presentation/pages/query_log_page.dart
@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutterhole/dependency_injection.dart';
import 'package:flutterhole/features/pihole_api/blocs/query_log_bloc.dart';
-import 'package:flutterhole/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart';
+import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/pihole_api/presentation/notifiers/queries_search_notifier.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/queries_search_app_bar.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/queries_search_list_builder.dart';
import 'package:flutterhole/features/pihole_api/presentation/widgets/query_log_page_overflow_refresher.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/single_query_data_tile.dart';
import 'package:flutterhole/features/routing/presentation/widgets/default_drawer.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
import 'package:flutterhole/features/settings/services/preference_service.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
final _numberFormat = NumberFormat();
@@ -25,7 +30,6 @@ class _PopupMenu extends StatelessWidget {
Widget build(BuildContext context) {
return PopupMenuButton(
tooltip: 'Set max results',
-// initialValue: getIt().queryLogMaxResults,
onSelected: (int value) async {
getIt().setQueryLogMaxResults(value);
@@ -37,7 +41,6 @@ class _PopupMenu extends StatelessWidget {
BlocProvider.of(context)
.add(QueryLogEvent.fetchSome(value));
},
-
itemBuilder: (BuildContext context) => >[
PopupMenuItem(
child: Text('Max results'),
@@ -56,34 +59,51 @@ class _PopupMenu extends StatelessWidget {
class QueryLogPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return BlocProvider(
- create: (_) =>
- QueryLogBloc()
- ..add(QueryLogEvent.fetchSome(
- getIt().queryLogMaxResults)),
- child: PiholeThemeBuilder(
- child: Scaffold(
- drawer: DefaultDrawer(),
- appBar: AppBar(
- title: Text('Query log'),
- elevation: 0.0,
- actions: [
- _PopupMenu(),
- ],
- ),
- body: QueryLogPageOverflowRefresher(
- child: BlocBuilder(
- builder: (BuildContext context, QueryLogState state) {
- return state.maybeWhen(
- success: (queries) {
- return ManyQueryTilesBuilder(queries: queries);
- },
- initial: () => Container(),
- orElse: () {
- return CenteredLoadingIndicator();
- },
- );
- },
+ return ChangeNotifierProvider(
+ create: (BuildContext context) => QueriesSearchNotifier(),
+ child: BlocProvider(
+ create: (_) => QueryLogBloc()
+ ..add(QueryLogEvent.fetchSome(
+ getIt().queryLogMaxResults)),
+ child: PiholeThemeBuilder(
+ child: Scaffold(
+ drawer: DefaultDrawer(),
+ appBar: QueriesSearchAppBar(
+ title: Text('Query log'),
+ actions: [
+ _PopupMenu(),
+ ],
+ ),
+ body: Scrollbar(
+ child: BlocBuilder(
+ builder: (BuildContext context, QueryLogState state) {
+ return state.maybeWhen(
+ success: (List queries) {
+ return QueriesSearchListBuilder(
+ initialData: queries,
+ builder:
+ (BuildContext context, List matches) {
+ return QueryLogPageOverflowRefresher(
+ child: ListView.builder(
+ itemCount: matches.length,
+ itemBuilder: (context, index) {
+ final QueryData query =
+ matches.elementAt(index);
+
+ return SingleQueryDataTile(query: query);
+ },
+ ),
+ );
+ },
+ );
+ },
+ initial: () => Container(),
+ orElse: () {
+ return CenteredLoadingIndicator();
+ },
+ );
+ },
+ ),
),
),
),
diff --git a/lib/features/pihole_api/presentation/pages/single_client_page.dart b/lib/features/pihole_api/presentation/pages/single_client_page.dart
index 25a99a25..6b9c2ee5 100644
--- a/lib/features/pihole_api/presentation/pages/single_client_page.dart
+++ b/lib/features/pihole_api/presentation/pages/single_client_page.dart
@@ -2,10 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutterhole/features/pihole_api/blocs/single_client_bloc.dart';
import 'package:flutterhole/features/pihole_api/data/models/pi_client.dart';
-import 'package:flutterhole/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart';
+import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/pihole_api/presentation/notifiers/queries_search_notifier.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/queries_search_app_bar.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/queries_search_list_builder.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/single_client_page_overflow_refresher.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/single_query_data_tile.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
import 'package:flutterhole/widgets/layout/failure_indicators.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
+import 'package:provider/provider.dart';
class SingleClientPage extends StatelessWidget {
const SingleClientPage({
@@ -17,28 +23,41 @@ class SingleClientPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return BlocProvider(
- create: (_) =>
- SingleClientBloc()..add(SingleClientEvent.fetchQueries(client)),
- child: PiholeThemeBuilder(
- child: Scaffold(
- appBar: AppBar(
- title: Text('${client.titleOrIp}'),
- ),
- body: Center(
- child: Builder(
- builder: (context) {
- return BlocBuilder(
- builder: (BuildContext context, SingleClientState state) {
- return state.maybeWhen(
- success: (client, queries) =>
- ManyQueryTilesBuilder(queries: queries),
- failure: (failure) => CenteredFailureIndicator(failure),
- orElse: () => CenteredLoadingIndicator(),
- );
- },
- );
- },
+ return ChangeNotifierProvider(
+ create: (BuildContext context) => QueriesSearchNotifier(),
+ child: BlocProvider(
+ create: (_) =>
+ SingleClientBloc()..add(SingleClientEvent.fetchQueries(client)),
+ child: PiholeThemeBuilder(
+ child: Scaffold(
+ appBar: QueriesSearchAppBar(
+ title: Text('${client.titleOrIp}'),
+ ),
+ body: Scrollbar(
+ child: BlocBuilder(
+ builder: (BuildContext context, SingleClientState state) {
+ return state.maybeWhen(
+ success: (client, queries) => QueriesSearchListBuilder(
+ initialData: queries,
+ builder: (context, matches) {
+ return SingleClientPageOverflowRefresher(
+ client: client,
+ child: ListView.builder(
+ itemCount: matches.length,
+ itemBuilder: (context, index) {
+ final QueryData query =
+ matches.elementAt(index);
+
+ return SingleQueryDataTile(query: query);
+ },
+ ),
+ );
+ }),
+ failure: (failure) => CenteredFailureIndicator(failure),
+ orElse: () => CenteredLoadingIndicator(),
+ );
+ },
+ ),
),
),
),
diff --git a/lib/features/pihole_api/presentation/pages/single_domain_page.dart b/lib/features/pihole_api/presentation/pages/single_domain_page.dart
index 4fc9e77d..040d1d44 100644
--- a/lib/features/pihole_api/presentation/pages/single_domain_page.dart
+++ b/lib/features/pihole_api/presentation/pages/single_domain_page.dart
@@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutterhole/features/pihole_api/blocs/single_domain_bloc.dart';
-import 'package:flutterhole/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart';
+import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/pihole_api/presentation/notifiers/queries_search_notifier.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/queries_search_app_bar.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/queries_search_list_builder.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/single_domain_page_overflow_refresher.dart';
+import 'package:flutterhole/features/pihole_api/presentation/widgets/single_query_data_tile.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
import 'package:flutterhole/widgets/layout/failure_indicators.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
+import 'package:provider/provider.dart';
class SingleDomainPage extends StatelessWidget {
const SingleDomainPage({
@@ -16,31 +22,44 @@ class SingleDomainPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return BlocProvider(
- create: (_) =>
- SingleDomainBloc()..add(SingleDomainEvent.fetchQueries(domain)),
- child: PiholeThemeBuilder(
- child: Scaffold(
- appBar: AppBar(
- title: Text(
- '$domain',
- overflow: TextOverflow.fade,
+ return ChangeNotifierProvider(
+ create: (BuildContext context) => QueriesSearchNotifier(),
+ child: BlocProvider(
+ create: (_) =>
+ SingleDomainBloc()..add(SingleDomainEvent.fetchQueries(domain)),
+ child: PiholeThemeBuilder(
+ child: Scaffold(
+ appBar: QueriesSearchAppBar(
+ title: Text(
+ '$domain',
+ overflow: TextOverflow.fade,
+ ),
),
- ),
- body: Center(
- child: Builder(
- builder: (context) {
- return BlocBuilder(
- builder: (BuildContext context, SingleDomainState state) {
- return state.maybeWhen(
- success: (domain, queries) =>
- ManyQueryTilesBuilder(queries: queries),
- failure: (failure) => CenteredFailureIndicator(failure),
- orElse: () => CenteredLoadingIndicator(),
- );
- },
- );
- },
+ body: Scrollbar(
+ child: BlocBuilder(
+ builder: (BuildContext context, SingleDomainState state) {
+ return state.maybeWhen(
+ success: (domain, queries) => QueriesSearchListBuilder(
+ initialData: queries,
+ builder: (context, matches) {
+ return SingleDomainPageOverflowRefresher(
+ domain: domain,
+ child: ListView.builder(
+ itemCount: matches.length,
+ itemBuilder: (context, index) {
+ final QueryData query =
+ matches.elementAt(index);
+
+ return SingleQueryDataTile(query: query);
+ },
+ ),
+ );
+ }),
+ failure: (failure) => CenteredFailureIndicator(failure),
+ orElse: () => CenteredLoadingIndicator(),
+ );
+ },
+ ),
),
),
),
diff --git a/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart b/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart
deleted file mode 100644
index 0bceb6da..00000000
--- a/lib/features/pihole_api/presentation/widgets/many_query_tiles_builder.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
-import 'package:flutterhole/features/pihole_api/presentation/widgets/single_query_data_tile.dart';
-import 'package:flutterhole/widgets/layout/animate_on_build.dart';
-
-class ManyQueryTilesBuilder extends StatelessWidget {
- const ManyQueryTilesBuilder({
- Key key,
- @required this.queries,
- this.shrinkWrap = false,
- }) : super(key: key);
-
- final List queries;
- final bool shrinkWrap;
-
- @override
- Widget build(BuildContext context) {
- return AnimateOnBuild(
- child: Scrollbar(
- child: ListView.builder(
- shrinkWrap: shrinkWrap,
- itemCount: queries.length,
- itemBuilder: (context, index) {
- final QueryData query = queries.elementAt(index);
-
- return SingleQueryDataTile(query: query);
- }),
- ),
- );
- }
-}
diff --git a/lib/features/pihole_api/presentation/widgets/queries_search_app_bar.dart b/lib/features/pihole_api/presentation/widgets/queries_search_app_bar.dart
new file mode 100644
index 00000000..9377df3b
--- /dev/null
+++ b/lib/features/pihole_api/presentation/widgets/queries_search_app_bar.dart
@@ -0,0 +1,115 @@
+import 'package:flutter/material.dart';
+import 'package:flutterhole/constants.dart';
+import 'package:flutterhole/features/pihole_api/presentation/notifiers/queries_search_notifier.dart';
+import 'package:flutterhole/widgets/layout/animate_on_build.dart';
+import 'package:provider/provider.dart';
+
+class QueriesSearchAppBar extends StatefulWidget
+ implements PreferredSizeWidget {
+ QueriesSearchAppBar({
+ Key key,
+ this.title,
+ this.actions,
+ }) : super(key: key);
+
+ final Widget title;
+ final List actions;
+
+ @override
+ Size get preferredSize => Size.fromHeight(56.0);
+
+ @override
+ _QueriesSearchAppBarState createState() => _QueriesSearchAppBarState();
+}
+
+class _QueriesSearchAppBarState extends State {
+ TextEditingController _searchEditingController;
+
+ @override
+ void initState() {
+ super.initState();
+ _searchEditingController = TextEditingController();
+ _searchEditingController.addListener(() {
+ setState(() {
+ Provider.of(context, listen: false).searchQuery =
+ _searchEditingController.text;
+ });
+ });
+ }
+
+ @override
+ void dispose() {
+ _searchEditingController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (
+ BuildContext context,
+ QueriesSearchNotifier notifier,
+ _,
+ ) {
+ return AppBar(
+ leading: notifier.isSearching
+ ? WillPopScope(
+ child: IconButton(
+ tooltip: 'Stop searching',
+ icon: Icon(KIcons.back),
+ onPressed: () {
+ notifier.stopSearching();
+ },
+ ),
+ onWillPop: () async {
+ notifier.stopSearching();
+ return false;
+ },
+ )
+ : null,
+ title: notifier.isSearching
+ ? AnimateOnBuild(
+ child: TextField(
+ controller: _searchEditingController,
+ autofocus: true,
+ keyboardType: TextInputType.url,
+ style: Theme.of(context).textTheme.headline6.apply(
+ color: Theme.of(context).colorScheme.onPrimary,
+ ),
+ decoration: InputDecoration(
+ hintText: 'Search queries...',
+ border: InputBorder.none,
+ suffixIcon: _searchEditingController.text.isEmpty
+ ? null
+ : IconButton(
+ tooltip: 'Clear search',
+ icon: Icon(KIcons.close),
+ color: Theme.of(context).colorScheme.onPrimary,
+ onPressed: () {
+ setState(() {
+ _searchEditingController.text = '';
+ });
+ },
+ ),
+ ),
+ ),
+ )
+ : widget.title ?? Container(),
+ elevation: 0.0,
+ actions: notifier.isSearching
+ ? []
+ : [
+ IconButton(
+ tooltip: 'Search queries',
+ icon: Icon(KIcons.search),
+ onPressed: () {
+ notifier.startSearching();
+ },
+ ),
+ ...widget.actions ?? [],
+ ],
+ );
+ },
+ );
+ }
+}
diff --git a/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart b/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart
new file mode 100644
index 00000000..96accc68
--- /dev/null
+++ b/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/pihole_api/presentation/notifiers/queries_search_notifier.dart';
+import 'package:provider/provider.dart';
+
+extension QueryDataSearchable on QueryData {
+ String get searchableString => [
+ queryType,
+ domain,
+ clientName,
+ queryStatus,
+ dnsSecStatus,
+ ].toString().toLowerCase();
+}
+
+typedef Widget SearchListBuilder(BuildContext context, List matches);
+
+class QueriesSearchListBuilder extends StatelessWidget {
+ const QueriesSearchListBuilder({
+ Key key,
+ @required this.initialData,
+ @required this.builder,
+ }) : super(key: key);
+
+ final List initialData;
+ final SearchListBuilder builder;
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(builder: (
+ BuildContext context,
+ QueriesSearchNotifier notifier,
+ _,
+ ) {
+ List searchedQueries;
+
+ if (notifier.isSearching && notifier.searchQuery.isNotEmpty) {
+ searchedQueries = initialData.where((element) {
+ final string = element.searchableString;
+ print('checking for "${notifier.searchQuery}" in "$string');
+ return string.contains(notifier.searchQuery);
+ }).toList();
+ }
+
+ final List toUse = searchedQueries ?? initialData;
+
+ return builder(context, toUse);
+ });
+ }
+}
diff --git a/lib/features/pihole_api/presentation/widgets/single_client_page_overflow_refresher.dart b/lib/features/pihole_api/presentation/widgets/single_client_page_overflow_refresher.dart
new file mode 100644
index 00000000..95c18f17
--- /dev/null
+++ b/lib/features/pihole_api/presentation/widgets/single_client_page_overflow_refresher.dart
@@ -0,0 +1,62 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutterhole/features/pihole_api/blocs/single_client_bloc.dart';
+import 'package:flutterhole/features/pihole_api/data/models/pi_client.dart';
+import 'package:pull_to_refresh/pull_to_refresh.dart';
+
+typedef void OnRefreshCallback(BuildContext context);
+
+class SingleClientPageOverflowRefresher extends StatefulWidget {
+ const SingleClientPageOverflowRefresher({
+ Key key,
+ @required this.client,
+ @required this.child,
+ }) : super(key: key);
+
+ final PiClient client;
+ final Widget child;
+
+ @override
+ _SingleClientPageOverflowRefresherState createState() =>
+ _SingleClientPageOverflowRefresherState();
+}
+
+class _SingleClientPageOverflowRefresherState
+ extends State {
+ final RefreshController _refreshController = RefreshController();
+
+ void _onRefresh() {
+ BlocProvider.of(context)
+ .add(SingleClientEvent.fetchQueries(widget.client));
+ }
+
+ @override
+ void dispose() {
+ _refreshController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return RefreshConfiguration(
+ headerBuilder: () => WaterDropMaterialHeader(
+ semanticsLabel: 'Refresh data',
+ color: Theme.of(context).colorScheme.onBackground,
+ ),
+ enableBallisticLoad: true,
+ child: BlocListener(
+ listener: (BuildContext context, SingleClientState state) {
+ if (state is SingleClientStateSuccess) {
+ _refreshController?.refreshCompleted();
+ }
+ },
+ child: SmartRefresher(
+ enablePullDown: true,
+ controller: _refreshController,
+ onRefresh: _onRefresh,
+ child: widget.child,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/pihole_api/presentation/widgets/single_domain_page_overflow_refresher.dart b/lib/features/pihole_api/presentation/widgets/single_domain_page_overflow_refresher.dart
new file mode 100644
index 00000000..c992b948
--- /dev/null
+++ b/lib/features/pihole_api/presentation/widgets/single_domain_page_overflow_refresher.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutterhole/features/pihole_api/blocs/single_domain_bloc.dart';
+import 'package:pull_to_refresh/pull_to_refresh.dart';
+
+typedef void OnRefreshCallback(BuildContext context);
+
+class SingleDomainPageOverflowRefresher extends StatefulWidget {
+ const SingleDomainPageOverflowRefresher({
+ Key key,
+ @required this.domain,
+ @required this.child,
+ }) : super(key: key);
+
+ final String domain;
+ final Widget child;
+
+ @override
+ _SingleDomainPageOverflowRefresherState createState() =>
+ _SingleDomainPageOverflowRefresherState();
+}
+
+class _SingleDomainPageOverflowRefresherState
+ extends State {
+ final RefreshController _refreshController = RefreshController();
+
+ void _onRefresh() {
+ BlocProvider.of(context)
+ .add(SingleDomainEvent.fetchQueries(widget.domain));
+ }
+
+ @override
+ void dispose() {
+ _refreshController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return RefreshConfiguration(
+ headerBuilder: () => WaterDropMaterialHeader(
+ semanticsLabel: 'Refresh data',
+ color: Theme.of(context).colorScheme.onBackground,
+ ),
+ enableBallisticLoad: true,
+ child: BlocListener(
+ listener: (BuildContext context, SingleDomainState state) {
+ if (state is SingleDomainStateSuccess) {
+ _refreshController?.refreshCompleted();
+ }
+ },
+ child: SmartRefresher(
+ enablePullDown: true,
+ controller: _refreshController,
+ onRefresh: _onRefresh,
+ child: widget.child,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart b/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart
index 55faa893..e7621f6a 100644
--- a/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart
+++ b/lib/features/pihole_api/presentation/widgets/single_query_data_tile.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutterhole/constants.dart';
import 'package:flutterhole/core/convert.dart';
import 'package:flutterhole/features/pihole_api/data/models/query_data.dart';
+import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
import 'package:flutterhole/widgets/layout/animated_opener.dart';
import 'package:flutterhole/widgets/layout/open_url_tile.dart';
@@ -127,101 +128,100 @@ class SingleQueryDataTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AnimatedOpener(
- closed: (context) =>
- ListTile(
- title: Text('${query.domain}'),
- isThreeLine: true,
- subtitle: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('$_timeStamp'),
- Text('${query.queryStatus.toFullString}',
- style: TextStyle(color: query.queryStatus.toColor)),
- ],
- ),
- trailing: _buildQueryStatusIcon(),
+ closed: (context) => ListTile(
+ title: Text('${query.domain}'),
+ isThreeLine: true,
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('$_timeStamp'),
+ Text('${query.queryStatus.toFullString}',
+ style: TextStyle(color: query.queryStatus.toColor)),
+ ],
+ ),
+ trailing: _buildQueryStatusIcon(),
+ ),
+ opened: (context) => PiholeThemeBuilder(
+ child: Scaffold(
+ appBar: AppBar(
+ title: Text('Query data'),
),
- opened: (context) =>
- Scaffold(
- appBar: AppBar(
- title: Text('Query data'),
- ),
- body: ListView(
- children: [
- ListTile(
- leading: Icon(KIcons.timestamp),
- title: Text('$_timeStamp'),
- subtitle: Text('Timestamp'),
- ),
- ListTile(
- leading: Icon(KIcons.queryType),
- title: Text('${query.queryType.toFullString}'),
- subtitle: Text('DNS record type'),
- trailing: IconButton(
- tooltip: 'Wat are DNS record types?',
- icon: Icon(KIcons.moreInfo),
- onPressed: () {
- showDialog(
- context: context,
- builder: (context) {
- return SimpleDialog(
- title: Text('Wat are DNS record types?'),
- children: [
- ...List.generate(QueryType.values.length,
- (index) {
- final QueryType type =
- QueryType.values.elementAt(index);
- return ListTile(
- dense: true,
- title: Text('${type.toFullString}'),
- subtitle: Text('${type.toDescription}'),
- );
- }),
- OpenUrlTile(
- url: _wikipediaUrl,
- leading: Icon(KIcons.info),
- title: Row(
- children: [
- Text('Learn more on '),
- Text(
- 'Wikipedia',
- style: TextStyle(
- fontWeight: FontWeight.bold),
- )
- ],
- ),
- )
- ],
- );
- },
- );
- },
- ),
- ),
- ListTile(
- leading: Icon(KIcons.domains),
- title: Text('${query.domain}'),
- subtitle: Text('Domain'),
- ),
- ListTile(
- leading: Icon(KIcons.clients),
- title: Text('${query.clientName}'),
- subtitle: Text('Client'),
+ body: ListView(
+ children: [
+ ListTile(
+ leading: Icon(KIcons.timestamp),
+ title: Text('$_timeStamp'),
+ subtitle: Text('Timestamp'),
+ ),
+ ListTile(
+ leading: Icon(KIcons.queryType),
+ title: Text('${query.queryType.toFullString}'),
+ subtitle: Text('DNS record type'),
+ trailing: IconButton(
+ tooltip: 'Wat are DNS record types?',
+ icon: Icon(KIcons.moreInfo),
+ onPressed: () {
+ showDialog(
+ context: context,
+ builder: (context) {
+ return SimpleDialog(
+ title: Text('Wat are DNS record types?'),
+ children: [
+ ...List.generate(QueryType.values.length,
+ (index) {
+ final QueryType type =
+ QueryType.values.elementAt(index);
+ return ListTile(
+ dense: true,
+ title: Text('${type.toFullString}'),
+ subtitle: Text('${type.toDescription}'),
+ );
+ }),
+ OpenUrlTile(
+ url: _wikipediaUrl,
+ leading: Icon(KIcons.info),
+ title: Row(
+ children: [
+ Text('Learn more on '),
+ Text(
+ 'Wikipedia',
+ style:
+ TextStyle(fontWeight: FontWeight.bold),
+ )
+ ],
+ ),
+ )
+ ],
+ );
+ },
+ );
+ },
),
- ListTile(
- leading: Icon(KIcons.queryStatus),
- title: Text('${query.queryStatus.toFullString}'),
- subtitle: Text('Status'),
- trailing: _buildQueryStatusIcon(),
- ),
- ListTile(
- leading: Icon(KIcons.pingInterval),
- title: Text(
- '${query.replyDuration.inMicroseconds ~/ 100} ms'),
- subtitle: Text('Reply duration'),
- ),
- ],
- ),
+ ),
+ ListTile(
+ leading: Icon(KIcons.domains),
+ title: Text('${query.domain}'),
+ subtitle: Text('Domain'),
+ ),
+ ListTile(
+ leading: Icon(KIcons.clients),
+ title: Text('${query.clientName}'),
+ subtitle: Text('Client'),
+ ),
+ ListTile(
+ leading: Icon(KIcons.queryStatus),
+ title: Text('${query.queryStatus.toFullString}'),
+ subtitle: Text('Status'),
+ trailing: _buildQueryStatusIcon(),
+ ),
+ ListTile(
+ leading: Icon(KIcons.pingInterval),
+ title: Text('${query.replyDuration.inMicroseconds ~/ 100} ms'),
+ subtitle: Text('Reply duration'),
+ ),
+ ],
+ ),
+ ),
),
);
}
diff --git a/lib/features/settings/presentation/widgets/pihole_theme_builder.dart b/lib/features/settings/presentation/widgets/pihole_theme_builder.dart
index 5d8da880..b198770f 100644
--- a/lib/features/settings/presentation/widgets/pihole_theme_builder.dart
+++ b/lib/features/settings/presentation/widgets/pihole_theme_builder.dart
@@ -21,7 +21,7 @@ Map _materialColorMap = {
class PiholeThemeBuilder extends StatelessWidget {
/// Applies theme data from the active [PiholeSettings].
///
- /// Optionally applies theme data from [settings].
+ /// Optionally overrides theme data from [settings].
const PiholeThemeBuilder({
Key key,
@required this.child,
diff --git a/release_notes.txt b/release_notes.txt
index 740749c4..25a7026d 100644
--- a/release_notes.txt
+++ b/release_notes.txt
@@ -1,3 +1,7 @@
+* Feature: query logs are searchable and refreshable.
+
+* Feature: the query log is back. I intend to make some kind of live query viewer separately, this page does _not_ automatically listen for new data.
+
* Feature: single queries can be expanded to show more data again.
* Feature: Basic Authentication is back - thank you Joerg.
From 4cf2b5b5cb0408df7b9677eb14d34e8b584b26f5 Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Tue, 19 May 2020 19:29:00 +0200
Subject: [PATCH 05/11] Toggles use theme color
---
.../pages/user_preferences_page.dart | 56 +++++++------------
.../widgets/pihole_theme_builder.dart | 11 ++--
.../settings/services/preference_service.dart | 2 +-
3 files changed, 29 insertions(+), 40 deletions(-)
diff --git a/lib/features/settings/presentation/pages/user_preferences_page.dart b/lib/features/settings/presentation/pages/user_preferences_page.dart
index 326a082a..1b1bd40e 100644
--- a/lib/features/settings/presentation/pages/user_preferences_page.dart
+++ b/lib/features/settings/presentation/pages/user_preferences_page.dart
@@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutterhole/constants.dart';
-import 'package:flutterhole/features/settings/presentation/notifiers/theme_mode_notifier.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
import 'package:flutterhole/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart';
import 'package:flutterhole/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart';
import 'package:flutterhole/widgets/layout/dialogs.dart';
import 'package:flutterhole/widgets/layout/list_title.dart';
-import 'package:provider/provider.dart';
class UserPreferencesPage extends StatefulWidget {
@override
@@ -16,40 +14,28 @@ class UserPreferencesPage extends StatefulWidget {
class _UserPreferencesPageState extends State {
@override
Widget build(BuildContext context) {
- return Consumer(
- builder: (
- BuildContext context,
- ThemeModeNotifier notifier,
- _,
- ) {
- return PiholeThemeBuilder(
- child: Scaffold(
- appBar: AppBar(
- title: Text('Preferences'),
- actions: [
- // TODO this throws some exceptions from ThemeModeNotifier after clearing and re-opening the page
- // _PopupMenu(),
- ],
+ return PiholeThemeBuilder(
+ child: Scaffold(
+ appBar: AppBar(
+ title: Text('Preferences'),
+ ),
+ body: ListView(
+ children: [
+ ListTitle('Customization'),
+ ThemeRadioPreferences(),
+ ListTitle('Data'),
+ UseNumbersApiSwitchPreference(),
+ ListTitle('Misc'),
+ ListTile(
+ leading: Icon(KIcons.welcome),
+ title: Text('Show welcome message'),
+ onTap: () {
+ showWelcomeDialog(context);
+ },
),
- body: ListView(
- children: [
- ListTitle('Customization'),
- ThemeRadioPreferences(),
- ListTitle('Data'),
- UseNumbersApiSwitchPreference(),
- ListTitle('Misc'),
- ListTile(
- leading: Icon(KIcons.welcome),
- title: Text('Show welcome message'),
- onTap: () {
- showWelcomeDialog(context);
- },
- ),
- ],
- ),
- ),
- );
- },
+ ],
+ ),
+ ),
);
}
}
diff --git a/lib/features/settings/presentation/widgets/pihole_theme_builder.dart b/lib/features/settings/presentation/widgets/pihole_theme_builder.dart
index b198770f..b3e7d3f5 100644
--- a/lib/features/settings/presentation/widgets/pihole_theme_builder.dart
+++ b/lib/features/settings/presentation/widgets/pihole_theme_builder.dart
@@ -54,11 +54,14 @@ class PiholeThemeBuilder extends StatelessWidget {
}
ThemeData _buildTheme(BuildContext context, PiholeSettings settings) {
+ final MaterialColor color = MaterialColor(
+ settings.primaryColor.value,
+ _materialColorMap,
+ );
+
return Theme.of(context).copyWith(
- accentColor: MaterialColor(
- settings.primaryColor.value,
- _materialColorMap,
- ),
+ accentColor: color,
+ toggleableActiveColor: color,
);
}
}
diff --git a/lib/features/settings/services/preference_service.dart b/lib/features/settings/services/preference_service.dart
index d55c73c6..99778d54 100644
--- a/lib/features/settings/services/preference_service.dart
+++ b/lib/features/settings/services/preference_service.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart' show ThemeMode;
+import 'package:flutter/material.dart';
/// Constant preference keys for map-like storage implementations.
class KPrefs {
From 7c53759bcf8fcfab871efd27c6a5815d495b8af6 Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Tue, 19 May 2020 21:42:10 +0200
Subject: [PATCH 06/11] Status indicators in Pi-hole forms can be tapped to
show error details
---
.../presentation/pages/about_page.dart | 2 +-
.../presentation/pages/add_pihole_page.dart | 2 -
.../pages/single_pihole_settings_page.dart | 2 -
.../form/authentication_status_icon.dart | 35 ++++--
.../form/host_details_status_icon.dart | 104 ++++++++++--------
lib/widgets/layout/dialogs.dart | 24 ++++
lib/widgets/layout/failure_indicators.dart | 31 ++++++
7 files changed, 143 insertions(+), 57 deletions(-)
diff --git a/lib/features/routing/presentation/pages/about_page.dart b/lib/features/routing/presentation/pages/about_page.dart
index 8c110526..2db49d3c 100644
--- a/lib/features/routing/presentation/pages/about_page.dart
+++ b/lib/features/routing/presentation/pages/about_page.dart
@@ -79,7 +79,7 @@ class AboutPage extends StatelessWidget {
'\n\n'
'FlutterHole is open source, which means anyone '
'can view the code that runs your app. '
- 'You can find the repository on Github.'),
+ 'You can find the repository on GitHub.'),
],
),
),
diff --git a/lib/features/settings/presentation/pages/add_pihole_page.dart b/lib/features/settings/presentation/pages/add_pihole_page.dart
index 34a467a4..2679931e 100644
--- a/lib/features/settings/presentation/pages/add_pihole_page.dart
+++ b/lib/features/settings/presentation/pages/add_pihole_page.dart
@@ -165,7 +165,6 @@ class _AddPiholePageState extends State {
title: Row(
children: [
Text('Host details'),
- SizedBox(width: 8.0),
HostDetailsStatusIcon(),
],
),
@@ -190,7 +189,6 @@ class _AddPiholePageState extends State {
title: Row(
children: [
Text('Authentication'),
- SizedBox(width: 8.0),
AuthenticationStatusIcon(),
],
),
diff --git a/lib/features/settings/presentation/pages/single_pihole_settings_page.dart b/lib/features/settings/presentation/pages/single_pihole_settings_page.dart
index 2c1b4b9e..4c855f9d 100644
--- a/lib/features/settings/presentation/pages/single_pihole_settings_page.dart
+++ b/lib/features/settings/presentation/pages/single_pihole_settings_page.dart
@@ -265,7 +265,6 @@ class _HostDetailsForm extends StatelessWidget {
title: Row(
children: [
Text('Host details'),
- SizedBox(width: 8.0),
HostDetailsStatusIcon(),
],
),
@@ -303,7 +302,6 @@ class _AuthenticationFormState extends State {
title: Row(
children: [
Text('Authentication'),
- SizedBox(width: 8.0),
AuthenticationStatusIcon(),
],
),
diff --git a/lib/features/settings/presentation/widgets/form/authentication_status_icon.dart b/lib/features/settings/presentation/widgets/form/authentication_status_icon.dart
index ee12cad2..f1585725 100644
--- a/lib/features/settings/presentation/widgets/form/authentication_status_icon.dart
+++ b/lib/features/settings/presentation/widgets/form/authentication_status_icon.dart
@@ -5,7 +5,9 @@ import 'package:flutterhole/constants.dart';
import 'package:flutterhole/core/models/failures.dart';
import 'package:flutterhole/features/settings/blocs/pihole_settings_bloc.dart';
import 'package:flutterhole/features/settings/data/models/pihole_settings.dart';
+import 'package:flutterhole/widgets/layout/failure_indicators.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
+import 'package:flutterhole/widgets/layout/snackbars.dart';
class AuthenticationStatusIcon extends StatelessWidget {
const AuthenticationStatusIcon({
@@ -22,23 +24,38 @@ class AuthenticationStatusIcon extends StatelessWidget {
_,
__,
dartz.Either authenticatedStatus,
- ___,
+ ___,
) {
return authenticatedStatus.fold(
- (Failure failure) => Icon(
- KIcons.error,
- color: KColors.error,
+ (Failure failure) =>
+ FailureIconButton(
+ failure: failure,
+ title: Text('Authentication failed'),
),
(bool isAuthenticated) {
- return Icon(
- isAuthenticated ? KIcons.success : KIcons.error,
- color: isAuthenticated ? KColors.success : KColors.error,
+ return isAuthenticated
+ ? IconButton(
+ icon: Icon(
+ KIcons.success,
+ color: KColors.success,
+ ),
+ onPressed: () {
+ showInfoSnackBar(
+ context, 'Authentication successful');
+ },
+ )
+ : FailureIconButton(
+ failure: Failure('Is your API token correct?'),
+ title: Text('Authentication failed'),
);
},
);
},
- loading: () => LoadingIcon(),
- orElse: () => Icon(KIcons.debug, color: Colors.transparent,));
+ orElse: () =>
+ IconButton(
+ icon: LoadingIcon(),
+ onPressed: null,
+ ));
},
);
}
diff --git a/lib/features/settings/presentation/widgets/form/host_details_status_icon.dart b/lib/features/settings/presentation/widgets/form/host_details_status_icon.dart
index b03ff27e..1ec8c861 100644
--- a/lib/features/settings/presentation/widgets/form/host_details_status_icon.dart
+++ b/lib/features/settings/presentation/widgets/form/host_details_status_icon.dart
@@ -6,7 +6,28 @@ import 'package:flutterhole/core/models/failures.dart';
import 'package:flutterhole/features/pihole_api/data/models/pi_status.dart';
import 'package:flutterhole/features/settings/blocs/pihole_settings_bloc.dart';
import 'package:flutterhole/features/settings/data/models/pihole_settings.dart';
+import 'package:flutterhole/widgets/layout/failure_indicators.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
+import 'package:flutterhole/widgets/layout/snackbars.dart';
+
+class _SuccessIconButton extends StatelessWidget {
+ const _SuccessIconButton({
+ Key key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ icon: Icon(
+ KIcons.success,
+ color: KColors.success,
+ ),
+ onPressed: () {
+ showInfoSnackBar(context, 'Host connection established');
+ },
+ );
+ }
+}
class HostDetailsStatusIcon extends StatelessWidget {
@override
@@ -14,49 +35,46 @@ class HostDetailsStatusIcon extends StatelessWidget {
return BlocBuilder(
builder: (BuildContext context, PiholeSettingsState state) {
return state.maybeWhen(
- validated: (
- PiholeSettings settings,
- dartz.Either hostStatusCode,
- dartz.Either piholeStatus,
- _,
- __,
- ) {
- return hostStatusCode.fold(
- (Failure failure) => Icon(
- KIcons.error,
- color: KColors.error,
- ),
- (int statusCode) {
- return piholeStatus.fold(
- (Failure failure) => Icon(
- KIcons.error,
- color: KColors.error,
- ),
- (PiStatusEnum piStatus) {
- switch (piStatus) {
- case PiStatusEnum.enabled:
- case PiStatusEnum.disabled:
- return Icon(
- KIcons.success,
- color: KColors.success,
- );
- case PiStatusEnum.unknown:
- default:
- return Icon(
- KIcons.error,
- color: KColors.error,
- );
- }
- },
- );
- },
- );
- },
- loading: () => LoadingIcon(),
- orElse: () => Icon(
- KIcons.debug,
- color: Colors.transparent,
- ));
+ validated: (
+ PiholeSettings settings,
+ dartz.Either hostStatusCode,
+ dartz.Either piholeStatus,
+ _,
+ __,
+ ) {
+ return hostStatusCode.fold(
+ (Failure failure) => FailureIconButton(
+ failure: failure,
+ title: Text('Fetching host status failed'),
+ ),
+ (int statusCode) {
+ return piholeStatus.fold(
+ (Failure failure) => FailureIconButton(
+ failure: failure,
+ title: Text('Fetching Pi-hole status failed'),
+ ),
+ (PiStatusEnum piStatus) {
+ switch (piStatus) {
+ case PiStatusEnum.enabled:
+ case PiStatusEnum.disabled:
+ return _SuccessIconButton();
+ case PiStatusEnum.unknown:
+ default:
+ return FailureIconButton(
+ failure: null,
+ title: Text('Unknown Pi-hole status'),
+ );
+ }
+ },
+ );
+ },
+ );
+ },
+ orElse: () => IconButton(
+ icon: LoadingIcon(),
+ onPressed: null,
+ ),
+ );
},
);
}
diff --git a/lib/widgets/layout/dialogs.dart b/lib/widgets/layout/dialogs.dart
index 460b4b44..2d0b15f5 100644
--- a/lib/widgets/layout/dialogs.dart
+++ b/lib/widgets/layout/dialogs.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:flutterhole/core/models/failures.dart';
Future showWelcomeDialog(BuildContext context) {
return showDialog(
@@ -62,3 +63,26 @@ Future showConfirmationDialog(
return result ?? false;
}
+
+Future showFailureDialog(
+ BuildContext context,
+ Failure failure, {
+ Widget title,
+}) async {
+ showDialog(
+ context: context,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ title: title,
+ content: SingleChildScrollView(
+ child: ListBody(
+ children: [
+ Text('${failure?.message ?? 'unknown failure'}'),
+ Text('${failure?.error?.toString() ?? ''}'),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+}
diff --git a/lib/widgets/layout/failure_indicators.dart b/lib/widgets/layout/failure_indicators.dart
index f22956fb..19f05347 100644
--- a/lib/widgets/layout/failure_indicators.dart
+++ b/lib/widgets/layout/failure_indicators.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
+import 'package:flutterhole/constants.dart';
import 'package:flutterhole/core/models/failures.dart';
+import 'package:flutterhole/widgets/layout/dialogs.dart';
class CenteredFailureIndicator extends StatelessWidget {
const CenteredFailureIndicator(
@@ -20,3 +22,32 @@ class CenteredFailureIndicator extends StatelessWidget {
);
}
}
+
+class FailureIconButton extends StatelessWidget {
+ const FailureIconButton({
+ Key key,
+ @required this.failure,
+ this.title,
+ }) : super(key: key);
+
+ final Failure failure;
+ final Widget title;
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ tooltip: 'Show failure',
+ icon: Icon(
+ KIcons.error,
+ color: KColors.error,
+ ),
+ onPressed: () {
+ showFailureDialog(
+ context,
+ failure,
+ title: title,
+ );
+ },
+ );
+ }
+}
From b5e0dfe5adfdcc2cae1274171bc1f80abb2943ba Mon Sep 17 00:00:00 2001
From: thomas-sterrenburg
Date: Wed, 20 May 2020 16:58:11 +0200
Subject: [PATCH 07/11] Adding queries over time chart
More verbose error logging
Adding drawer footer and better about dialog
Adding apiTokenRequired field, fixes #79
---
lib/constants.dart | 7 +-
lib/core/convert.dart | 2 +
lib/core/models/exceptions.dart | 14 +-
.../pages/summary/summary_page_view.dart | 20 ++-
.../total_queries_over_day_line_chart.dart | 147 ++++++++++++++++++
.../widgets/total_queries_over_day_tile.dart | 45 ++++++
.../data/datasources/api_data_source.dart | 6 +
.../data/datasources/api_data_source_dio.dart | 39 +++--
.../data/models/forward_destinations.dart | 2 -
.../presentation/pages/query_log_page.dart | 2 +
.../widgets/queries_search_list_builder.dart | 9 +-
.../presentation/pages/about_page.dart | 137 ++++++----------
.../presentation/widgets/default_drawer.dart | 103 ++++++++----
.../settings_data_source_hive.dart | 4 +-
.../settings/data/models/pihole_settings.dart | 12 +-
.../pages/user_preferences_page.dart | 2 +
.../widgets/form/api_token_form_tile.dart | 110 ++++++++-----
.../widgets/form/detected_versions_tile.dart | 80 ++++++----
.../footer_message_string_preference.dart | 19 +++
.../services/package_info_service.dart | 9 ++
.../services/package_info_service_impl.dart | 19 +++
.../settings/services/preference_service.dart | 3 +
.../services/preference_service_impl.dart | 8 +-
lib/widgets/layout/copy_button.dart | 129 +++++++++++++++
lib/widgets/layout/dialogs.dart | 61 ++++++++
lib/widgets/layout/failure_indicators.dart | 18 ++-
release_notes.txt | 10 +-
.../numbers_api_data_source_dio_test.dart | 8 +-
.../numbers_api_repository_impl_test.dart | 4 +-
.../datasources/api_data_source_dio_test.dart | 25 ++-
.../api_repository_impl_test.dart | 22 +--
.../connection_repository_dio_test.dart | 18 ++-
.../settings_repository_impl_test.dart | 10 +-
test/fixtures/pihole_settings_default.json | 1 +
34 files changed, 830 insertions(+), 275 deletions(-)
create mode 100644 lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_line_chart.dart
create mode 100644 lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_tile.dart
create mode 100644 lib/features/settings/presentation/widgets/preferences/footer_message_string_preference.dart
create mode 100644 lib/features/settings/services/package_info_service.dart
create mode 100644 lib/features/settings/services/package_info_service_impl.dart
create mode 100644 lib/widgets/layout/copy_button.dart
diff --git a/lib/constants.dart b/lib/constants.dart
index ab609587..e3ae786a 100644
--- a/lib/constants.dart
+++ b/lib/constants.dart
@@ -8,6 +8,10 @@ class KStrings {
static const String piholeSettingsActive = 'active';
static const String playStoreUrl =
'https://play.google.com/store/apps/details?id=sterrenburg.github.flutterhole';
+ static const String githubHomeUrl =
+ 'https://github.com/sterrenburg/flutterhole/';
+ static const String githubIssuesUrl =
+ 'https://github.com/sterrenburg/flutterhole/issues/new/choose';
}
class KIcons {
@@ -37,7 +41,7 @@ class KIcons {
static const IconData settings = MaterialIcons.settings;
static const IconData preferences = MaterialIcons.format_paint;
static const IconData about = MaterialCommunityIcons.heart;
- static const IconData log = AntDesign.codesquare;
+ static const IconData apiLog = AntDesign.codesquare;
static const IconData open = MaterialIcons.keyboard_arrow_right;
static const IconData search = MaterialIcons.search;
@@ -108,6 +112,7 @@ class KColors {
static const Color queryStatus = Colors.brown;
static const Color dnsSec = Colors.blueGrey;
static const Color timestamp = Colors.amber;
+ static const Color link = Colors.blue;
static const Color info = Colors.blue;
static const Color debug = Colors.brown;
diff --git a/lib/core/convert.dart b/lib/core/convert.dart
index d68c1dce..a28a16c2 100644
--- a/lib/core/convert.dart
+++ b/lib/core/convert.dart
@@ -12,6 +12,8 @@ extension DateTimeWithRelative on DateTime {
String get formattedDate => Jiffy(this).format('yyyy-MM-d');
String get formattedTime => Jiffy(this).format('h:mm:ss a');
+
+ String get formattedTimeShort => Jiffy(this).format('hh:mm');
}
extension StringExtension on String {
diff --git a/lib/core/models/exceptions.dart b/lib/core/models/exceptions.dart
index 7ceb5fcd..d659c93f 100644
--- a/lib/core/models/exceptions.dart
+++ b/lib/core/models/exceptions.dart
@@ -3,20 +3,22 @@ import 'package:flutterhole/features/pihole_api/data/models/model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'exceptions.freezed.dart';
-
part 'exceptions.g.dart';
@freezed
abstract class PiException extends MapModel with _$PiException {
- const factory PiException.notFound() = NotFoundPiException;
+ const factory PiException.notFound(Object error) = NotFoundPiException;
- const factory PiException.timeOut() = TimeOutPiException;
+ const factory PiException.timeOut(Object error) = TimeOutPiException;
- const factory PiException.notAuthenticated() = NotAuthenticatedPiException;
+ const factory PiException.notAuthenticated(Object error) =
+ NotAuthenticatedPiException;
- const factory PiException.emptyResponse() = EmptyResponsePiException;
+ const factory PiException.emptyResponse(Object error) =
+ EmptyResponsePiException;
- const factory PiException.malformedResponse() = MalformedResponsePiException;
+ const factory PiException.malformedResponse(Object error) =
+ MalformedResponsePiException;
factory PiException.fromJson(Map json) =>
_$PiExceptionFromJson(json);
diff --git a/lib/features/home/presentation/pages/summary/summary_page_view.dart b/lib/features/home/presentation/pages/summary/summary_page_view.dart
index 68f8b582..bfb571dd 100644
--- a/lib/features/home/presentation/pages/summary/summary_page_view.dart
+++ b/lib/features/home/presentation/pages/summary/summary_page_view.dart
@@ -6,10 +6,12 @@ import 'package:flutterhole/features/home/blocs/home_bloc.dart';
import 'package:flutterhole/features/home/presentation/pages/summary/widgets/forward_destinations_tile.dart';
import 'package:flutterhole/features/home/presentation/pages/summary/widgets/query_types_tile.dart';
import 'package:flutterhole/features/home/presentation/pages/summary/widgets/summary_tile.dart';
+import 'package:flutterhole/features/home/presentation/pages/summary/widgets/total_queries_over_day_tile.dart';
import 'package:flutterhole/features/home/presentation/widgets/home_bloc_builder.dart';
import 'package:flutterhole/features/home/presentation/widgets/home_page_overflow_refresher.dart';
import 'package:flutterhole/features/pihole_api/data/models/dns_query_type.dart';
import 'package:flutterhole/features/pihole_api/data/models/forward_destinations.dart';
+import 'package:flutterhole/features/pihole_api/data/models/over_time_data.dart';
import 'package:flutterhole/features/pihole_api/data/models/summary.dart';
import 'package:flutterhole/widgets/layout/failure_indicators.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
@@ -25,13 +27,15 @@ class SummaryPageView extends StatelessWidget {
return HomeBlocBuilder(builder: (BuildContext context, HomeState state) {
return state.maybeWhen(
failure: (failure) => CenteredFailureIndicator(failure),
- success: (Either summaryResult,
- _,
- __,
- ___,
- Either
- forwardDestinationsResult,
- Either dnsQueryTypesResult,) =>
+ success: (
+ Either summaryResult,
+ Either queriesOverTimeResult,
+ _,
+ __,
+ Either
+ forwardDestinationsResult,
+ Either dnsQueryTypesResult,
+ ) =>
summaryResult.fold(
(failure) => CenteredFailureIndicator(failure),
(summary) =>
@@ -67,6 +71,7 @@ class SummaryPageView extends StatelessWidget {
color: Colors.red,
integer: summary.domainsBeingBlocked,
),
+ TotalQueriesOverDayTile(queriesOverTimeResult),
QueryTypesTile(dnsQueryTypesResult),
ForwardDestinationsTile(forwardDestinationsResult),
],
@@ -77,6 +82,7 @@ class SummaryPageView extends StatelessWidget {
StaggeredTile.count(4, 1),
StaggeredTile.count(4, 3),
StaggeredTile.count(4, 3),
+ StaggeredTile.count(4, 3),
],
),
),
diff --git a/lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_line_chart.dart b/lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_line_chart.dart
new file mode 100644
index 00000000..5d555b65
--- /dev/null
+++ b/lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_line_chart.dart
@@ -0,0 +1,147 @@
+import 'package:fl_chart/fl_chart.dart';
+import 'package:flutter/material.dart';
+import 'package:flutterhole/constants.dart';
+import 'package:flutterhole/core/convert.dart';
+import 'package:flutterhole/features/pihole_api/data/models/over_time_data.dart';
+
+const double kHorizontalLineInterval = 200.0;
+const double kVerticalLineInterval = 30.0;
+
+class TotalQueriesOverDayLineChart extends StatelessWidget {
+ const TotalQueriesOverDayLineChart(
+ this.overTimeData, {
+ Key key,
+ }) : super(key: key);
+
+ final OverTimeData overTimeData;
+
+ List _spotsFromMap(Map map) {
+ List spots = [];
+ int index = 0;
+
+ map.forEach((DateTime timestamp, int hits) {
+ spots.add(FlSpot(index.toDouble(), hits.toDouble()));
+ index++;
+ });
+
+ return spots;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return LineChart(
+ LineChartData(
+ titlesData: FlTitlesData(
+ leftTitles: SideTitles(
+ textStyle: Theme.of(context).textTheme.caption,
+ showTitles: true,
+ interval: kHorizontalLineInterval,
+ getTitles: (double value) {
+ return '${value.round()}';
+ },
+ ),
+ bottomTitles: SideTitles(
+ showTitles: true,
+ interval: kVerticalLineInterval,
+ textStyle: Theme.of(context).textTheme.caption,
+ getTitles: (double value) {
+ final dateTime =
+ overTimeData.domainsOverTime.keys.elementAt(value.round());
+
+ return '${dateTime.formattedTimeShort}';
+ }),
+ ),
+ lineBarsData: [
+ LineChartBarData(
+ colors: [KColors.success],
+ spots: _spotsFromMap(overTimeData.domainsOverTime),
+ barWidth: 4.0,
+ dotData: FlDotData(
+ show: false,
+ ),
+ belowBarData: BarAreaData(
+ show: true,
+ colors: [
+ KColors.success.withOpacity(0.5),
+ KColors.success.withOpacity(0.25),
+ ],
+ gradientColorStops: [0.5, 1.0],
+ gradientFrom: const Offset(0, 0),
+ gradientTo: const Offset(0, 1),
+ ),
+ ),
+ LineChartBarData(
+ colors: [KColors.blocked],
+ spots: _spotsFromMap(overTimeData.adsOverTime),
+ barWidth: 4.0,
+ dotData: FlDotData(
+ show: false,
+ ),
+ belowBarData: BarAreaData(
+ show: true,
+ colors: [
+ KColors.blocked.withOpacity(0.5),
+ KColors.blocked.withOpacity(0.25),
+ ],
+ gradientColorStops: [0.5, 1.0],
+ gradientFrom: const Offset(0, 0),
+ gradientTo: const Offset(0, 1),
+ ),
+ ),
+ ],
+ gridData: FlGridData(
+ show: true,
+ drawVerticalLine: true,
+ checkToShowHorizontalLine: (double value) {
+ return value.round() % kHorizontalLineInterval == 0;
+ },
+ checkToShowVerticalLine: (double value) {
+ return value.round() % kVerticalLineInterval == 0;
+ },
+ ),
+ lineTouchData: LineTouchData(
+ touchTooltipData: LineTouchTooltipData(
+ tooltipBgColor: Theme.of(context).cardColor,
+ getTooltipItems: (List touchedSpots) {
+ if (touchedSpots == null) {
+ return null;
+ }
+
+ return touchedSpots.map((LineBarSpot touchedSpot) {
+ if (touchedSpot == null) {
+ return null;
+ }
+
+ final Color color = touchedSpot.bar.colors[0];
+
+ if (color == KColors.success) {
+ return LineTooltipItem(
+ 'Permitted: ${touchedSpot.y.round()}',
+ Theme.of(context).textTheme.bodyText1.apply(
+ color: color,
+ ),
+ );
+ } else if (color == KColors.blocked) {
+ return LineTooltipItem(
+ 'Blocked: ${touchedSpot.y.round()}',
+ Theme.of(context).textTheme.bodyText1.apply(
+ color: color,
+ ),
+ );
+ }
+
+ return LineTooltipItem(
+ '${touchedSpot.y.round()}',
+ Theme.of(context).textTheme.bodyText1.apply(
+ color: color,
+ ),
+ );
+ }).toList();
+ },
+ ),
+ ),
+ borderData: FlBorderData(show: false),
+ ),
+ );
+ }
+}
diff --git a/lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_tile.dart b/lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_tile.dart
new file mode 100644
index 00000000..f2fe60e4
--- /dev/null
+++ b/lib/features/home/presentation/pages/summary/widgets/total_queries_over_day_tile.dart
@@ -0,0 +1,45 @@
+import 'package:dartz/dartz.dart';
+import 'package:flutter/material.dart';
+import 'package:flutterhole/core/models/failures.dart';
+import 'package:flutterhole/features/home/presentation/pages/summary/widgets/total_queries_over_day_line_chart.dart';
+import 'package:flutterhole/features/pihole_api/data/models/over_time_data.dart';
+import 'package:flutterhole/widgets/layout/failure_indicators.dart';
+
+class TotalQueriesOverDayTile extends StatelessWidget {
+ const TotalQueriesOverDayTile(
+ this.queriesOverTimeResult, {
+ Key key,
+ }) : super(key: key);
+
+ final Either queriesOverTimeResult;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: SafeArea(
+ minimum: EdgeInsets.all(8.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16.0),
+ child: Text(
+ 'Total queries over last 24 hours',
+ style: TextStyle(fontSize: 16),
+ ),
+ ),
+ Expanded(
+ child: queriesOverTimeResult.fold(
+ (failure) => CenteredFailureIndicator(failure),
+ (overTimeData) {
+ return TotalQueriesOverDayLineChart(overTimeData);
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/pihole_api/data/datasources/api_data_source.dart b/lib/features/pihole_api/data/datasources/api_data_source.dart
index 80ee62a0..fac03fe2 100644
--- a/lib/features/pihole_api/data/datasources/api_data_source.dart
+++ b/lib/features/pihole_api/data/datasources/api_data_source.dart
@@ -10,6 +10,12 @@ import 'package:flutterhole/features/pihole_api/data/models/top_items.dart';
import 'package:flutterhole/features/pihole_api/data/models/top_sources.dart';
import 'package:flutterhole/features/settings/data/models/pihole_settings.dart';
+/// The string that counts as the API token on Pi-holes
+/// without authentication.
+///
+/// https://github.com/sterrenburg/flutterhole/issues/79
+const String kNoApiTokenNeeded = 'No password set';
+
abstract class ApiDataSource {
Future fetchSummary(PiholeSettings settings);
diff --git a/lib/features/pihole_api/data/datasources/api_data_source_dio.dart b/lib/features/pihole_api/data/datasources/api_data_source_dio.dart
index 1b36b731..68e45e11 100644
--- a/lib/features/pihole_api/data/datasources/api_data_source_dio.dart
+++ b/lib/features/pihole_api/data/datasources/api_data_source_dio.dart
@@ -62,8 +62,10 @@ class ApiDataSourceDio implements ApiDataSource {
}
try {
+ final url = '${settings.baseUrl}:${settings.apiPort}${settings.apiPath}';
+
final Response response = await _dio.get(
- '${settings.baseUrl}:${settings.apiPort}${settings.apiPath}',
+ url,
queryParameters: queryParameters,
options: Options(
headers: headers,
@@ -75,11 +77,11 @@ class ApiDataSourceDio implements ApiDataSource {
final data = response.data;
if (data is String) {
- if (data.isEmpty) throw EmptyResponsePiException();
+ if (data.isEmpty) throw EmptyResponsePiException(data);
}
if (data is List && data.isEmpty) {
- throw EmptyResponsePiException();
+ throw EmptyResponsePiException(data);
}
return data;
@@ -88,17 +90,17 @@ class ApiDataSourceDio implements ApiDataSource {
case DioErrorType.CONNECT_TIMEOUT:
case DioErrorType.SEND_TIMEOUT:
case DioErrorType.RECEIVE_TIMEOUT:
- throw TimeOutPiException();
+ throw TimeOutPiException(e);
case DioErrorType.RESPONSE:
- throw NotFoundPiException();
+ throw NotFoundPiException(e);
case DioErrorType.CANCEL:
case DioErrorType.DEFAULT:
default:
switch (e.response?.statusCode ?? 0) {
case 404:
- throw NotFoundPiException();
+ throw NotFoundPiException(e);
default:
- throw MalformedResponsePiException();
+ throw MalformedResponsePiException(e);
}
}
}
@@ -108,15 +110,22 @@ class ApiDataSourceDio implements ApiDataSource {
PiholeSettings settings, {
Map queryParameters = const {},
}) async {
- if (settings.apiToken.isEmpty) throw NotAuthenticatedPiException();
+ String apiToken = settings.apiToken;
+
+ if (settings.apiTokenRequired) {
+ if (apiToken.isEmpty)
+ throw NotAuthenticatedPiException('API token is empty');
+ } else {
+ apiToken = kNoApiTokenNeeded;
+ }
- queryParameters.addAll({'auth': settings.apiToken});
+ queryParameters.addAll({'auth': apiToken});
try {
final result = await _get(settings, queryParameters: queryParameters);
return result;
- } on EmptyResponsePiException catch (_) {
- throw NotAuthenticatedPiException();
+ } on EmptyResponsePiException catch (e) {
+ throw NotAuthenticatedPiException(e);
}
}
@@ -236,7 +245,7 @@ class ApiDataSourceDio implements ApiDataSource {
PiClient client,
) async {
final Map json =
- await _getSecure(settings, queryParameters: {
+ await _getSecure(settings, queryParameters: {
'getAllQueries': '',
'client': (client.title != null && client.title.isNotEmpty)
? client.title.trim()
@@ -247,8 +256,10 @@ class ApiDataSourceDio implements ApiDataSource {
}
@override
- Future fetchQueryDataForDomain(PiholeSettings settings,
- String domain,) async {
+ Future fetchQueryDataForDomain(
+ PiholeSettings settings,
+ String domain,
+ ) async {
final Map json =
await _getSecure(settings, queryParameters: {
'getAllQueries': '',
diff --git a/lib/features/pihole_api/data/models/forward_destinations.dart b/lib/features/pihole_api/data/models/forward_destinations.dart
index 7beece79..71a55a2e 100644
--- a/lib/features/pihole_api/data/models/forward_destinations.dart
+++ b/lib/features/pihole_api/data/models/forward_destinations.dart
@@ -56,8 +56,6 @@ abstract class ForwardDestinationsResult extends MapModel
_ForwardDestinationsResult;
factory ForwardDestinationsResult.fromJson(Map json) {
- print('json: $json');
-
return _$ForwardDestinationsResultFromJson(json);
}
}
diff --git a/lib/features/pihole_api/presentation/pages/query_log_page.dart b/lib/features/pihole_api/presentation/pages/query_log_page.dart
index f24b222c..d339476e 100644
--- a/lib/features/pihole_api/presentation/pages/query_log_page.dart
+++ b/lib/features/pihole_api/presentation/pages/query_log_page.dart
@@ -11,6 +11,7 @@ import 'package:flutterhole/features/pihole_api/presentation/widgets/single_quer
import 'package:flutterhole/features/routing/presentation/widgets/default_drawer.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:flutterhole/widgets/layout/failure_indicators.dart';
import 'package:flutterhole/widgets/layout/loading_indicators.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@@ -97,6 +98,7 @@ class QueryLogPage extends StatelessWidget {
},
);
},
+ failure: (failure) => CenteredFailureIndicator(failure),
initial: () => Container(),
orElse: () {
return CenteredLoadingIndicator();
diff --git a/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart b/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart
index 96accc68..2b4f1b6e 100644
--- a/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart
+++ b/lib/features/pihole_api/presentation/widgets/queries_search_list_builder.dart
@@ -35,11 +35,10 @@ class QueriesSearchListBuilder extends StatelessWidget {
List searchedQueries;
if (notifier.isSearching && notifier.searchQuery.isNotEmpty) {
- searchedQueries = initialData.where((element) {
- final string = element.searchableString;
- print('checking for "${notifier.searchQuery}" in "$string');
- return string.contains(notifier.searchQuery);
- }).toList();
+ searchedQueries = initialData
+ .where((QueryData queryData) =>
+ queryData.searchableString.contains(notifier.searchQuery))
+ .toList();
}
final List toUse = searchedQueries ?? initialData;
diff --git a/lib/features/routing/presentation/pages/about_page.dart b/lib/features/routing/presentation/pages/about_page.dart
index 2db49d3c..10b12239 100644
--- a/lib/features/routing/presentation/pages/about_page.dart
+++ b/lib/features/routing/presentation/pages/about_page.dart
@@ -4,8 +4,9 @@ import 'package:flutterhole/dependency_injection.dart';
import 'package:flutterhole/features/browser/services/browser_service.dart';
import 'package:flutterhole/features/routing/presentation/pages/privacy_page.dart';
import 'package:flutterhole/features/routing/presentation/widgets/default_drawer.dart';
-import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
+import 'package:flutterhole/features/settings/services/package_info_service.dart';
import 'package:flutterhole/widgets/layout/animated_opener.dart';
+import 'package:flutterhole/widgets/layout/dialogs.dart';
import 'package:flutterhole/widgets/layout/list_title.dart';
import 'package:package_info/package_info.dart';
import 'package:share/share.dart';
@@ -13,94 +14,58 @@ import 'package:share/share.dart';
class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return PiholeThemeBuilder(
- child: FutureBuilder(
- future: PackageInfo.fromPlatform(),
- builder: (BuildContext context, AsyncSnapshot snapshot) {
- PackageInfo packageInfo;
- if (snapshot.hasData) {
- packageInfo = snapshot.data;
- }
+ final packageInfo = getIt().packageInfo;
- return Scaffold(
- drawer: DefaultDrawer(),
- body: CustomScrollView(
- slivers: [
- SliverAppBar(
- title: Text('About'),
- flexibleSpace: FlexibleSpaceBar(),
- ),
- SliverList(
- delegate: SliverChildListDelegate([
- Column(
+ return Scaffold(
+ drawer: DefaultDrawer(),
+ body: CustomScrollView(
+ slivers: [
+ SliverAppBar(
+ title: Text('About'),
+ flexibleSpace: FlexibleSpaceBar(),
+ ),
+ SliverList(
+ delegate: SliverChildListDelegate([
+ Column(
+ children: [
+ ListTile(
+ contentPadding: EdgeInsets.all(16),
+ title: Row(
children: [
- ListTile(
- contentPadding: EdgeInsets.all(16),
- title: Row(
- children: [
- Text(
- '${packageInfo?.appName}',
- style: Theme.of(context)
- .textTheme
- .headline4
- .copyWith(
- color: Theme.of(context).accentColor),
- ),
- ],
- ),
- subtitle: Text(
- 'Made by Sterrenburg',
- style: Theme.of(context).textTheme.caption,
- ),
- ),
- ListTile(
- leading: Icon(
- KIcons.version,
- color: KColors.success,
- ),
- trailing: FlatButton(
- onPressed: () {
- showAboutDialog(
- context: context,
- applicationName: '${packageInfo?.appName}',
- applicationVersion: '${packageInfo?.version}',
- applicationLegalese: 'Made by Sterrenburg',
- children: [
- SizedBox(height: 24),
- RichText(
- text: TextSpan(
- style:
- Theme.of(context).textTheme.bodyText2,
- children: [
- TextSpan(
- text:
- 'FlutterHole is a free third party Android application '
- 'for interacting with your Pi-Hole® server. '
- '\n\n'
- 'FlutterHole is open source, which means anyone '
- 'can view the code that runs your app. '
- 'You can find the repository on GitHub.'),
- ],
- ),
- ),
- ],
- );
- },
- child: Text('Details'),
- ),
- title: Text('Version'),
- subtitle: Text(
- '${packageInfo?.version} (build #${packageInfo?.buildNumber})'),
+ Text(
+ '${packageInfo?.appName}',
+ style: Theme.of(context)
+ .textTheme
+ .headline4
+ .copyWith(color: Theme.of(context).accentColor),
),
],
),
- Divider(),
- _AboutTiles(packageInfo: packageInfo),
- ])),
+ subtitle: Text(
+ 'Made by Sterrenburg',
+ style: Theme.of(context).textTheme.caption,
+ ),
+ ),
+ ListTile(
+ leading: Icon(
+ KIcons.version,
+ color: KColors.success,
+ ),
+ trailing: FlatButton(
+ onPressed: () {
+ showAppDetailsDialog(context, packageInfo);
+ },
+ child: Text('Details'),
+ ),
+ title: Text('Version'),
+ subtitle: Text('${packageInfo.versionAndBuildString}'),
+ ),
],
),
- );
- },
+ Divider(),
+ _AboutTiles(packageInfo: packageInfo),
+ ])),
+ ],
),
);
}
@@ -132,8 +97,8 @@ class _AboutTiles extends StatelessWidget {
_AboutTile(
KIcons.bugReport,
text: 'Submit a bug report',
- onTap: () => getIt().launchUrl(
- 'https://github.com/sterrenburg/flutterhole/issues/new'),
+ onTap: () =>
+ getIt().launchUrl(KStrings.githubIssuesUrl),
),
Divider(),
ListTitle('Support the developer'),
@@ -145,8 +110,8 @@ class _AboutTiles extends StatelessWidget {
_AboutTile(
KIcons.github,
text: 'Star on GitHub',
- onTap: () => getIt()
- .launchUrl('https://github.com/sterrenburg/flutterhole/'),
+ onTap: () =>
+ getIt().launchUrl(KStrings.githubHomeUrl),
),
Divider(),
ListTitle('Other'),
diff --git a/lib/features/routing/presentation/widgets/default_drawer.dart b/lib/features/routing/presentation/widgets/default_drawer.dart
index 7d4edd39..682366be 100644
--- a/lib/features/routing/presentation/widgets/default_drawer.dart
+++ b/lib/features/routing/presentation/widgets/default_drawer.dart
@@ -7,6 +7,10 @@ import 'package:flutterhole/features/routing/presentation/widgets/default_drawer
import 'package:flutterhole/features/routing/presentation/widgets/drawer_menu.dart';
import 'package:flutterhole/features/routing/presentation/widgets/drawer_tile.dart';
import 'package:flutterhole/features/routing/services/router_service.dart';
+import 'package:flutterhole/features/settings/services/package_info_service.dart';
+import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:flutterhole/widgets/layout/dialogs.dart';
+import 'package:package_info/package_info.dart';
import 'package:provider/provider.dart';
class DefaultDrawer extends StatelessWidget {
@@ -15,38 +19,46 @@ class DefaultDrawer extends StatelessWidget {
return ChangeNotifierProvider(
create: (BuildContext context) => DrawerNotifier(),
child: Drawer(
- child: ListView(
- padding: EdgeInsets.zero,
+ child: Stack(
children: [
- DefaultDrawerHeader(),
- DrawerMenu(),
- DrawerTile(
- routeName: RouterService.home,
- title: Text('Dashboard'),
- icon: Icon(KIcons.dashboard),
+ ListView(
+ padding: EdgeInsets.zero,
+ children: [
+ DefaultDrawerHeader(),
+ DrawerMenu(),
+ DrawerTile(
+ routeName: RouterService.home,
+ title: Text('Dashboard'),
+ icon: Icon(KIcons.dashboard),
+ ),
+ DrawerTile(
+ routeName: RouterService.queryLog,
+ title: Text('Query log'),
+ icon: Icon(KIcons.queryLog),
+ ),
+ DrawerTile(
+ routeName: RouterService.settings,
+ title: Text('Settings'),
+ icon: Icon(KIcons.settings),
+ ),
+ Divider(),
+ DrawerTile(
+ routeName: RouterService.about,
+ title: Text('About'),
+ icon: Icon(KIcons.about),
+ ),
+ ListTile(
+ title: Text('API Log'),
+ leading: Icon(KIcons.apiLog),
+ onTap: () {
+ getIt().showInspector();
+ },
+ ),
+ ],
),
- DrawerTile(
- routeName: RouterService.queryLog,
- title: Text('Query log'),
- icon: Icon(KIcons.queryLog),
- ),
- DrawerTile(
- routeName: RouterService.settings,
- title: Text('Settings'),
- icon: Icon(KIcons.settings),
- ),
- Divider(),
- DrawerTile(
- routeName: RouterService.about,
- title: Text('About'),
- icon: Icon(KIcons.about),
- ),
- ListTile(
- title: Text('API Log'),
- leading: Icon(KIcons.log),
- onTap: () {
- getIt().showInspector();
- },
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: _Footer(),
),
],
),
@@ -54,3 +66,34 @@ class DefaultDrawer extends StatelessWidget {
);
}
}
+
+class _Footer extends StatelessWidget {
+ const _Footer({
+ Key key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ final String footerMessage = getIt().footerMessage;
+ final PackageInfo packageInfo = getIt().packageInfo;
+
+ return ListTile(
+ title: Text(
+ '${packageInfo.appName} ${packageInfo.versionAndBuildString}',
+ style: Theme.of(context).textTheme.caption,
+ ),
+ subtitle: footerMessage.isEmpty
+ ? null
+ : Text(
+ '$footerMessage',
+ style: Theme
+ .of(context)
+ .textTheme
+ .caption,
+ ),
+ onLongPress: () {
+ showAppDetailsDialog(context, packageInfo);
+ },
+ );
+ }
+}
diff --git a/lib/features/settings/data/datasources/settings_data_source_hive.dart b/lib/features/settings/data/datasources/settings_data_source_hive.dart
index 72f339c6..69b599bc 100644
--- a/lib/features/settings/data/datasources/settings_data_source_hive.dart
+++ b/lib/features/settings/data/datasources/settings_data_source_hive.dart
@@ -72,7 +72,7 @@ class SettingsDataSourceHive implements SettingsDataSource {
final int index = all.indexOf(original);
if (index < 0) {
- throw PiException.notFound();
+ throw PiException.notFound(index);
}
await box.putAt(index, update.toJson());
@@ -116,7 +116,7 @@ class SettingsDataSourceHive implements SettingsDataSource {
final int index = all.indexOf(piholeSettings);
if (index < 0) {
- throw PiException.notFound();
+ throw PiException.notFound(index);
}
await _setActiveIndex(index);
diff --git a/lib/features/settings/data/models/pihole_settings.dart b/lib/features/settings/data/models/pihole_settings.dart
index 03b1df9a..251f7122 100644
--- a/lib/features/settings/data/models/pihole_settings.dart
+++ b/lib/features/settings/data/models/pihole_settings.dart
@@ -8,11 +8,15 @@ part 'pihole_settings.freezed.dart';
part 'pihole_settings.g.dart';
@freezed
-abstract class PiholeSettings extends MapModel with _$PiholeSettings {
+abstract class PiholeSettings extends MapModel implements _$PiholeSettings {
+ const PiholeSettings._();
+
const factory PiholeSettings({
// annotation
- @Default('Pihole') String title,
- @Default('') String description,
+ @Default('Pihole')
+ String title,
+ @Default('')
+ String description,
@Default(Color.fromRGBO(33, 150, 243, 1)) // i.e. `Colors.blue`
@JsonKey(fromJson: colorFromHex, toJson: colorToHex)
Color primaryColor,
@@ -26,10 +30,10 @@ abstract class PiholeSettings extends MapModel with _$PiholeSettings {
// authentication
@Default('') String apiToken,
+ @Default(true) bool apiTokenRequired,
@Default(false) bool allowSelfSignedCertificates,
@Default('') String basicAuthenticationUsername,
@Default('') String basicAuthenticationPassword,
-
// proxy
@Default('') String proxyUrl,
@Default(8080) int proxyPort,
diff --git a/lib/features/settings/presentation/pages/user_preferences_page.dart b/lib/features/settings/presentation/pages/user_preferences_page.dart
index 1b1bd40e..cab335d8 100644
--- a/lib/features/settings/presentation/pages/user_preferences_page.dart
+++ b/lib/features/settings/presentation/pages/user_preferences_page.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutterhole/constants.dart';
import 'package:flutterhole/features/settings/presentation/widgets/pihole_theme_builder.dart';
+import 'package:flutterhole/features/settings/presentation/widgets/preferences/footer_message_string_preference.dart';
import 'package:flutterhole/features/settings/presentation/widgets/preferences/theme_radio_preferences.dart';
import 'package:flutterhole/features/settings/presentation/widgets/preferences/use_numbers_api_switch_preference.dart';
import 'package:flutterhole/widgets/layout/dialogs.dart';
@@ -23,6 +24,7 @@ class _UserPreferencesPageState extends State {
children: [
ListTitle('Customization'),
ThemeRadioPreferences(),
+ FooterMessageStringPreference(),
ListTitle('Data'),
UseNumbersApiSwitchPreference(),
ListTitle('Misc'),
diff --git a/lib/features/settings/presentation/widgets/form/api_token_form_tile.dart b/lib/features/settings/presentation/widgets/form/api_token_form_tile.dart
index 56d9dac2..c0748577 100644
--- a/lib/features/settings/presentation/widgets/form/api_token_form_tile.dart
+++ b/lib/features/settings/presentation/widgets/form/api_token_form_tile.dart
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutterhole/constants.dart';
import 'package:flutterhole/dependency_injection.dart';
+import 'package:flutterhole/features/settings/blocs/pihole_settings_bloc.dart';
import 'package:flutterhole/features/settings/data/models/pihole_settings.dart';
import 'package:flutterhole/features/settings/services/qr_scan_service.dart';
@@ -42,45 +44,81 @@ class _ApiTokenFormTileState extends State {
@override
Widget build(BuildContext context) {
- return ListTile(
- title: FormBuilderTextField(
- attribute: 'apiToken',
- controller: _apiTokenController,
- decoration: widget.decoration.copyWith(
- labelText: 'API token',
- helperText:
- 'The API token can be found on the admin home at "Settings > API / Web interface". \nRequired for authenticated tasks, such as enabling & disabling.',
- helperMaxLines: 5,
- suffixIcon: Row(
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.end,
- children: [
- IconButton(
- tooltip: 'Toggle visibility',
- icon: Icon(
- _apiTokenVisible
- ? KIcons.visibility_on
- : KIcons.visibility_off,
+ return BlocBuilder(
+ condition: (previous, next) {
+ if (previous is PiholeSettingsStateValidated) {
+ return false;
+ }
+
+ return true;
+ }, builder: (BuildContext context, PiholeSettingsState state) {
+ final bool apiTokenFieldIsVisible = state.maybeMap(
+ validated: (state) => state.settings.apiTokenRequired == true,
+ orElse: () => true,
+ );
+
+ return Column(
+ children: [
+ Opacity(
+ opacity: apiTokenFieldIsVisible ? 1.0 : 0.3,
+ child: IgnorePointer(
+ ignoring: !apiTokenFieldIsVisible,
+ child: ListTile(
+ title: FormBuilderTextField(
+ attribute: 'apiToken',
+ controller: _apiTokenController,
+ decoration: widget.decoration.copyWith(
+ labelText: 'API token',
+ helperText:
+ 'The API token can be found on the admin home at "Settings > API / Web interface". Required for authenticated tasks, such as enabling & disabling.',
+ helperMaxLines: 5,
+ suffixIcon: Row(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ IconButton(
+ tooltip: 'Toggle visibility',
+ icon: Icon(
+ _apiTokenVisible
+ ? KIcons.visibility_on
+ : KIcons.visibility_off,
+ ),
+ onPressed: () {
+ setState(() {
+ _apiTokenVisible = !_apiTokenVisible;
+ });
+ },
+ ),
+ IconButton(
+ tooltip: 'Scan QR code',
+ icon: Icon(KIcons.qrCode),
+ onPressed: _scanQrCode,
+ ),
+ ],
+ ),
+ ),
+ autocorrect: false,
+ maxLines: 1,
+ obscureText: !_apiTokenVisible,
+ valueTransformer: (value) => (value ?? '').toString().trim(),
),
- onPressed: () {
- setState(() {
- _apiTokenVisible = !_apiTokenVisible;
- });
- },
),
- IconButton(
- tooltip: 'Scan QR code',
- icon: Icon(KIcons.qrCode),
- onPressed: _scanQrCode,
+ ),
+ ),
+ ListTile(
+ title: FormBuilderCheckbox(
+ attribute: 'apiTokenRequired',
+ decoration: widget.decoration.copyWith(
+ helperText:
+ 'If your Pi-hole does not use an API token because it has no password, disable this option.',
+ helperMaxLines: 3,
),
- ],
+ label: Text('Require API token for authenticated requests'),
+ ),
),
- ),
- autocorrect: false,
- maxLines: 1,
- obscureText: !_apiTokenVisible,
- valueTransformer: (value) => (value ?? '').toString().trim(),
- ),
- );
+ Divider(),
+ ],
+ );
+ });
}
}
diff --git a/lib/features/settings/presentation/widgets/form/detected_versions_tile.dart b/lib/features/settings/presentation/widgets/form/detected_versions_tile.dart
index 78c04ce0..6374440a 100644
--- a/lib/features/settings/presentation/widgets/form/detected_versions_tile.dart
+++ b/lib/features/settings/presentation/widgets/form/detected_versions_tile.dart
@@ -20,42 +20,56 @@ class DetectedVersionsTile extends StatelessWidget {
return true;
},
builder: (BuildContext context, PiholeSettingsState state) {
- return state.maybeMap(
- validated: (state) {
- return state.versions.fold(
- (failure) => CenteredFailureIndicator(failure),
- (versions) => Column(
- children: [
- ListTile(
- title: Text('Detected versions'),
- leading: Icon(
- KIcons.version,
- color: Theme.of(context).accentColor,
- ),
- ),
- _ListTile(
- title: 'Pi-hole Version',
- currentVersion: versions.currentCoreVersion,
- latestVersion: versions.latestCoreVersion,
- branch: versions.coreBranch,
- ),
- _ListTile(
- title: 'Web Interface Version',
- currentVersion: versions.currentWebVersion,
- latestVersion: versions.latestWebVersion,
- branch: versions.webBranch,
+ return Column(
+ children: [
+ ListTile(
+ title: Text('Detected versions'),
+ leading: Icon(
+ KIcons.version,
+ color: Theme.of(context).accentColor,
+ ),
+ ),
+ state.maybeMap(
+ validated: (state) {
+ return state.versions.fold(
+ (failure) => CenteredFailureIndicator(failure),
+ (versions) => Column(
+ children: [
+ _ListTile(
+ title: 'Pi-hole Version',
+ currentVersion: versions.currentCoreVersion,
+ latestVersion: versions.latestCoreVersion,
+ branch: versions.coreBranch,
+ ),
+ _ListTile(
+ title: 'Web Interface Version',
+ currentVersion: versions.currentWebVersion,
+ latestVersion: versions.latestWebVersion,
+ branch: versions.webBranch,
+ ),
+ _ListTile(
+ title: 'FTL Version',
+ currentVersion: versions.currentFtlVersion,
+ latestVersion: versions.latestFtlVersion,
+ branch: versions.ftlBranch,
+ ),
+ ],
),
- _ListTile(
- title: 'FTL Version',
- currentVersion: versions.currentFtlVersion,
- latestVersion: versions.latestFtlVersion,
- branch: versions.ftlBranch,
+ );
+ },
+ // Filling in some empty space in an inelegant fashion
+ loading: (_) => Column(
+ children: List.generate(
+ 3,
+ (index) => ListTile(
+ title: Text(''),
+ subtitle: Text(''),
),
- ],
+ ),
),
- );
- },
- orElse: () => Container(),
+ orElse: () => Container(),
+ ),
+ ],
);
},
);
diff --git a/lib/features/settings/presentation/widgets/preferences/footer_message_string_preference.dart b/lib/features/settings/presentation/widgets/preferences/footer_message_string_preference.dart
new file mode 100644
index 00000000..f9e7ca0f
--- /dev/null
+++ b/lib/features/settings/presentation/widgets/preferences/footer_message_string_preference.dart
@@ -0,0 +1,19 @@
+import 'package:flutter/material.dart';
+import 'package:flutterhole/features/settings/services/preference_service.dart';
+import 'package:preferences/preferences.dart';
+
+class FooterMessageStringPreference extends StatelessWidget {
+ const FooterMessageStringPreference({
+ Key key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ return TextFieldPreference(
+ 'Footer message',
+ KPrefs.footerMessage,
+ hintText: 'Shown at the bottom fo the drawer',
+ defaultVal: 'Made with ♡ by Sterrenburg',
+ );
+ }
+}
diff --git a/lib/features/settings/services/package_info_service.dart b/lib/features/settings/services/package_info_service.dart
new file mode 100644
index 00000000..9d0cc393
--- /dev/null
+++ b/lib/features/settings/services/package_info_service.dart
@@ -0,0 +1,9 @@
+import 'package:package_info/package_info.dart';
+
+extension PackageInfoPrintable on PackageInfo {
+ String get versionAndBuildString => '$version (build #$buildNumber)';
+}
+
+abstract class PackageInfoService {
+ PackageInfo get packageInfo;
+}
diff --git a/lib/features/settings/services/package_info_service_impl.dart b/lib/features/settings/services/package_info_service_impl.dart
new file mode 100644
index 00000000..5c59d61b
--- /dev/null
+++ b/lib/features/settings/services/package_info_service_impl.dart
@@ -0,0 +1,19 @@
+import 'package:flutterhole/features/settings/services/package_info_service.dart';
+import 'package:injectable/injectable.dart';
+import 'package:package_info/package_info.dart';
+
+@prod
+@singleton
+@RegisterAs(PackageInfoService)
+class PackageInfoServiceImpl implements PackageInfoService {
+ PackageInfoServiceImpl._(this._info);
+
+ @factoryMethod
+ static Future create() async =>
+ PackageInfoServiceImpl._(await PackageInfo.fromPlatform());
+
+ final PackageInfo _info;
+
+ @override
+ PackageInfo get packageInfo => _info;
+}
diff --git a/lib/features/settings/services/preference_service.dart b/lib/features/settings/services/preference_service.dart
index 99778d54..58c9bd12 100644
--- a/lib/features/settings/services/preference_service.dart
+++ b/lib/features/settings/services/preference_service.dart
@@ -8,6 +8,7 @@ class KPrefs {
static const String useNumbersApi = 'useNumbersApi';
static const String themeMode = 'themeMode';
static const String queryLogMaxResults = 'queryLogMaxResults';
+ static const String footerMessage = 'footerMessage';
}
const ThemeModeEnumMap = {
@@ -34,4 +35,6 @@ abstract class PreferenceService {
int get queryLogMaxResults;
Future setQueryLogMaxResults(int maxResults);
+
+ String get footerMessage;
}
diff --git a/lib/features/settings/services/preference_service_impl.dart b/lib/features/settings/services/preference_service_impl.dart
index 5211afdc..3b95eb1c 100644
--- a/lib/features/settings/services/preference_service_impl.dart
+++ b/lib/features/settings/services/preference_service_impl.dart
@@ -16,7 +16,7 @@ class PrServiceImpl implements PreferenceService {
}
dynamic _get(String key) {
- if (T == null) throw PiException.notFound();
+ if (T == null) throw PiException.notFound(TypeError());
switch (T) {
case String:
@@ -33,7 +33,7 @@ class PrServiceImpl implements PreferenceService {
case List:
return PrefService.getStringList(key);
default:
- throw TypeError();
+ throw PiException.emptyResponse(TypeError());
}
}
@@ -88,4 +88,8 @@ class PrServiceImpl implements PreferenceService {
@override
Future setQueryLogMaxResults(int maxResults) async =>
_set(KPrefs.queryLogMaxResults, maxResults);
+
+ @override
+ String get footerMessage =>
+ _get(KPrefs.footerMessage) ?? 'Made with ♡ by Sterrenburg';
}
diff --git a/lib/widgets/layout/copy_button.dart b/lib/widgets/layout/copy_button.dart
new file mode 100644
index 00000000..04f25680
--- /dev/null
+++ b/lib/widgets/layout/copy_button.dart
@@ -0,0 +1,129 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutterhole/constants.dart';
+import 'package:flutterhole/widgets/layout/snackbars.dart';
+
+const String _tooltip = 'Copy to clipboard';
+
+/// An [IconButton] with copy-to-clipboard support.
+///
+/// If [onCopy] is `null`, shows a [SnackBar].
+class CopyIconButton extends StatelessWidget {
+ const CopyIconButton(
+ this.data, {
+ Key key,
+ this.onCopy,
+ }) : assert(data != null),
+ super(key: key);
+
+ final String data;
+ final VoidCallback onCopy;
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ tooltip: _tooltip,
+ icon: Icon(KIcons.copy),
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: data)).then((_) {
+ if (onCopy != null)
+ onCopy();
+ else
+ showInfoSnackBar(
+ context,
+ 'Copied to clipboard: $data',
+ );
+ });
+ });
+ }
+}
+
+/// A [FlatButton] with copy-to-clipboard support.
+///
+/// If [onCopy] is `null`, shows a [SnackBar].
+class CopyFlatButton extends StatelessWidget {
+ const CopyFlatButton(
+ this.data, {
+ Key key,
+ this.onCopy,
+ }) : assert(data != null),
+ super(key: key);
+
+ final String data;
+ final VoidCallback onCopy;
+
+ @override
+ Widget build(BuildContext context) {
+ return FlatButton.icon(
+ label: Text(_tooltip),
+ icon: Icon(KIcons.copy),
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: data)).then((_) {
+ if (onCopy != null)
+ onCopy();
+ else
+ showInfoSnackBar(
+ context,
+ 'Copied to clipboard: $data',
+ );
+ });
+ });
+ }
+}
+
+class AnimatedCopyTile extends StatefulWidget {
+ const AnimatedCopyTile(
+ this.data, {
+ Key key,
+ }) : super(key: key);
+
+ final String data;
+
+ @override
+ _AnimatedCopyTileState createState() => _AnimatedCopyTileState();
+}
+
+class _AnimatedCopyTileState extends State {
+ bool copied = false;
+
+ @override
+ Widget build(BuildContext context) {
+ return Tooltip(
+ message: _tooltip,
+ child: ListTile(
+ title: Text(widget.data),
+ onTap: () async {
+ setState(() {
+ copied = true;
+ });
+
+ Clipboard.setData(ClipboardData(text: widget.data));
+ await Future.delayed(Duration(seconds: 2));
+ setState(() {
+ copied = false;
+ });
+ },
+ trailing: Stack(
+ alignment: Alignment.center,
+ children: [
+ AnimatedOpacity(
+ opacity: copied ? 1 : 0,
+ duration: kThemeChangeDuration,
+ child: Icon(
+ KIcons.success,
+ color: KColors.success,
+ ),
+ ),
+ AnimatedOpacity(
+ opacity: copied ? 0 : 1,
+ duration: kThemeChangeDuration,
+ child: Icon(
+ KIcons.copy,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/layout/dialogs.dart b/lib/widgets/layout/dialogs.dart
index 2d0b15f5..27086049 100644
--- a/lib/widgets/layout/dialogs.dart
+++ b/lib/widgets/layout/dialogs.dart
@@ -1,5 +1,13 @@
+import 'package:alice/alice.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'package:flutterhole/constants.dart';
import 'package:flutterhole/core/models/failures.dart';
+import 'package:flutterhole/dependency_injection.dart';
+import 'package:flutterhole/features/browser/services/browser_service.dart';
+import 'package:flutterhole/widgets/layout/copy_button.dart';
+import 'package:flutterhole/widgets/layout/snackbars.dart';
+import 'package:package_info/package_info.dart';
Future showWelcomeDialog(BuildContext context) {
return showDialog(
@@ -82,7 +90,60 @@ Future showFailureDialog(
],
),
),
+ actions: [
+ FlatButton.icon(
+ label: Text('API Log'),
+ icon: Icon(KIcons.apiLog),
+ onPressed: () {
+ getIt().showInspector();
+ },
+ ),
+ CopyFlatButton(
+ '${failure.toJson()}',
+ onCopy: () {
+ Navigator.of(dialogContext).pop();
+ showInfoSnackBar(context, 'Copied failure to clipboard');
+ },
+ ),
+ ],
);
},
);
}
+
+void showAppDetailsDialog(BuildContext context, PackageInfo packageInfo) {
+ return showAboutDialog(
+ context: context,
+ applicationName: '${packageInfo.appName}',
+ applicationVersion: '${packageInfo.version}',
+ applicationLegalese: 'Made by Sterrenburg',
+ children: [
+ SizedBox(height: 24),
+ RichText(
+ text: TextSpan(
+ children: [
+ TextSpan(
+ text: 'FlutterHole is a free third party Android application '
+ 'for interacting with your Pi-Hole® server. '
+ '\n\n'
+ 'FlutterHole is open source, which means anyone '
+ 'can view the code that runs your app. '
+ 'You can find the repository on '),
+ TextSpan(
+ style: Theme
+ .of(context)
+ .textTheme
+ .bodyText2
+ .apply(color: KColors.link),
+ text: 'GitHub',
+ recognizer: TapGestureRecognizer()
+ ..onTap = () =>
+ getIt().launchUrl(KStrings.githubHomeUrl),
+ ),
+ TextSpan(text: '.'),
+ ],
+ ),
+ ),
+ ],
+ );
+}
diff --git a/lib/widgets/layout/failure_indicators.dart b/lib/widgets/layout/failure_indicators.dart
index 19f05347..ecff409b 100644
--- a/lib/widgets/layout/failure_indicators.dart
+++ b/lib/widgets/layout/failure_indicators.dart
@@ -13,12 +13,18 @@ class CenteredFailureIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text('${failure.message ?? 'unknown failure'}'),
- Text('${failure.error?.toString()}'),
- ],
+ return Center(
+ child: ListTile(
+ title: Text('${failure.message ?? 'Unknown failure'}'),
+ subtitle: Text('${failure.error?.runtimeType ?? 'Unknown error'}'),
+ trailing: Icon(
+ KIcons.warning,
+ color: KColors.warning,
+ ),
+ onTap: () {
+ showFailureDialog(context, failure);
+ },
+ ),
);
}
}
diff --git a/release_notes.txt b/release_notes.txt
index 25a7026d..e840473d 100644
--- a/release_notes.txt
+++ b/release_notes.txt
@@ -1,9 +1,9 @@
-* Feature: query logs are searchable and refreshable.
+* Fix: Pi-holes without a password and API token can now toggle off the API token requirement.
-* Feature: the query log is back. I intend to make some kind of live query viewer separately, this page does _not_ automatically listen for new data.
+* Feature: the queries over time chart is back.
-* Feature: single queries can be expanded to show more data again.
+* Feature: errors are now more verbose.
-* Feature: Basic Authentication is back - thank you Joerg.
+* Feature: query logs are searchable and refreshable.
-* Fix: Tapping on the pie chart colors in Forward Destinations highlights the wrong category on the left - thank you Jeremy.
\ No newline at end of file
+* Feature: the query log is back. I intend to make some kind of live query viewer separately, this page does _not_ automatically listen for new data.
\ No newline at end of file
diff --git a/test/features/numbers_api/data/datasources/numbers_api_data_source_dio_test.dart b/test/features/numbers_api/data/datasources/numbers_api_data_source_dio_test.dart
index 5bf7f397..213c36b2 100644
--- a/test/features/numbers_api/data/datasources/numbers_api_data_source_dio_test.dart
+++ b/test/features/numbers_api/data/datasources/numbers_api_data_source_dio_test.dart
@@ -42,6 +42,8 @@ void main() async {
dataSource = NumbersApiDataSourceDio(dio);
});
+ final tError = PiException.emptyResponse(TypeError());
+
group('fetchTrivia', () {
// test(
// 'should return String on successful fetchTrivia',
@@ -60,8 +62,7 @@ void main() async {
'should throw on failed fetchTrivia',
() async {
// arrange
- when(httpClientAdapterMock.fetch(any, any, any))
- .thenThrow(PiException.notFound());
+ when(httpClientAdapterMock.fetch(any, any, any)).thenThrow(tError);
// act
// assert
expect(() => dataSource.fetchTrivia(42), throwsA(isA()));
@@ -89,8 +90,7 @@ void main() async {
'should throw on failed fetchManyTrivia',
() async {
// arrange
- when(httpClientAdapterMock.fetch(any, any, any))
- .thenThrow(PiException.notFound());
+ when(httpClientAdapterMock.fetch(any, any, any)).thenThrow(tError);
// act
// assert
expect(() => dataSource.fetchManyTrivia([1, 2, 3]),
diff --git a/test/features/numbers_api/data/repositories/numbers_api_repository_impl_test.dart b/test/features/numbers_api/data/repositories/numbers_api_repository_impl_test.dart
index d4b4810b..f16d21b3 100644
--- a/test/features/numbers_api/data/repositories/numbers_api_repository_impl_test.dart
+++ b/test/features/numbers_api/data/repositories/numbers_api_repository_impl_test.dart
@@ -21,6 +21,8 @@ void main() async {
numbersApiRepository = NumbersApiRepositoryImpl(mockNumbersApiDataSource);
});
+ final tError = PiException.emptyResponse(TypeError());
+
group('fetchTrivia', () {
test(
'should return String on successful fetchTrivia',
@@ -41,7 +43,6 @@ void main() async {
'should return $Failure on failed fetchTrivia',
() async {
// arrange
- final tError = PiException.emptyResponse();
when(mockNumbersApiDataSource.fetchTrivia(2)).thenThrow(tError);
// act
final Either result =
@@ -76,7 +77,6 @@ void main() async {
'should return $Failure on failed fetchManyTrivia',
() async {
// arrange
- final tError = PiException.emptyResponse();
when(mockNumbersApiDataSource.fetchManyTrivia([1, 2, 3]))
.thenThrow(tError);
// act
diff --git a/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart b/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart
index b461e4e1..5bd32c67 100644
--- a/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart
+++ b/test/features/pihole_api/data/datasources/api_data_source_dio_test.dart
@@ -165,14 +165,14 @@ void main() async {
);
test(
- 'should throw $NotAuthenticatedPiException on enablePihole without apiToken',
+ 'should throw $NotAuthenticatedPiException on enablePihole with empty apiToken',
() async {
// arrange
- stubStringResponse('[]', 200);
+// stubStringResponse('[]', 200);
piholeSettings = piholeSettings.copyWith(apiToken: '');
// assert
expect(() => apiDataSourceDio.enablePihole(piholeSettings),
- throwsA(isA()));
+ throwsA(NotAuthenticatedPiException('API token is empty')));
},
);
@@ -187,6 +187,23 @@ void main() async {
throwsA(isA