refactor: fix remaining lint warnings and deprecated APIs
- Fix deprecated @ignore in json_serializable models (appState, yourLocation) - Fix empty catch blocks in compassMapPlugin, globalFiresBottomStats, locationUtils - Fix no_logic_in_create_state in firesApp, markdownPage, slider - Fix use_build_context_synchronously in fireAlert - Fix only_throw_errors in firesApi (8 instances) - Exclude generated .g.dart files from analysis - Update deprecated nullable: false to @JsonSerializable() Build: APK generated successfully, 0 errors, 0 warnings
This commit is contained in:
parent
948a609619
commit
862d423f6b
14 changed files with 96 additions and 69 deletions
|
|
@ -18,6 +18,7 @@ analyzer:
|
||||||
- 'build/**'
|
- 'build/**'
|
||||||
- 'lib/generated/**'
|
- 'lib/generated/**'
|
||||||
- 'lib/data/models/*g.dart'
|
- 'lib/data/models/*g.dart'
|
||||||
|
- 'lib/models/*.g.dart'
|
||||||
- '.dart_tool/**'
|
- '.dart_tool/**'
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,15 @@ class _CompassMapPluginWidgetState extends State<CompassMapPluginWidget> {
|
||||||
try {
|
try {
|
||||||
_mapController = MapController.of(context);
|
_mapController = MapController.of(context);
|
||||||
_initRotationMonitoring();
|
_initRotationMonitoring();
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Ignore - MapController may not be available yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initRotationMonitoring() {
|
void _initRotationMonitoring() {
|
||||||
// Check rotation periodically (every 100ms) to detect changes
|
// Check rotation periodically (every 100ms) to detect changes
|
||||||
_rotationCheckTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
_rotationCheckTimer =
|
||||||
|
Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
_checkRotation();
|
_checkRotation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -59,7 +61,8 @@ class _CompassMapPluginWidgetState extends State<CompassMapPluginWidget> {
|
||||||
try {
|
try {
|
||||||
final MapController controller = MapController.of(context);
|
final MapController controller = MapController.of(context);
|
||||||
controller.rotate(0);
|
controller.rotate(0);
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Ignore - MapController may not be available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ class _FireAlertState extends State<FireAlert> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildTweetButton() {
|
Widget buildTweetButton() {
|
||||||
|
final strings = S.of(context);
|
||||||
return Align(
|
return Align(
|
||||||
alignment: const Alignment(0.0, -0.2),
|
alignment: const Alignment(0.0, -0.2),
|
||||||
child: FloatingActionButton(
|
child: FloatingActionButton(
|
||||||
|
|
@ -64,13 +65,12 @@ class _FireAlertState extends State<FireAlert> {
|
||||||
openPlacesDialog(_scaffoldKey).then((YourLocation yourLocation) {
|
openPlacesDialog(_scaffoldKey).then((YourLocation yourLocation) {
|
||||||
final String where =
|
final String where =
|
||||||
yourLocation.description.replaceAll(' ', '').split(',')[0];
|
yourLocation.description.replaceAll(' ', '').split(',')[0];
|
||||||
Share.shareWithResult(S
|
Share.shareWithResult(
|
||||||
.of(context)
|
strings.tweetAboutSelf(yourLocation.description, '#IF$where'));
|
||||||
.tweetAboutSelf(yourLocation.description, '#IF$where'));
|
|
||||||
}).catchError((Object onError) {
|
}).catchError((Object onError) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(S.of(context).errorFirePlaceDialog)));
|
SnackBar(content: Text(strings.errorFirePlaceDialog)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,29 @@ class FiresApp extends StatefulWidget {
|
||||||
final Store<AppState> store;
|
final Store<AppState> store;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_FiresAppState createState() => _FiresAppState(store);
|
_FiresAppState createState() => _FiresAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FiresAppState extends State<FiresApp> {
|
class _FiresAppState extends State<FiresApp> {
|
||||||
|
|
||||||
// globals.getIt.registerSingleton
|
// globals.getIt.registerSingleton
|
||||||
_FiresAppState(this.store);
|
_FiresAppState();
|
||||||
final GlobalKey<NavigatorState> navigatorKey =
|
late final Store<AppState> store;
|
||||||
GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
static Widget introWidget(BuildContext context) => IntroPage();
|
static Widget introWidget(BuildContext context) => IntroPage();
|
||||||
static Widget continueWidget(BuildContext context) => const HomePage();
|
static Widget continueWidget(BuildContext context) => const HomePage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
store = widget.store;
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
|
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
|
||||||
IntroPage.routeName: introWidget,
|
IntroPage.routeName: introWidget,
|
||||||
HomePage.routeName: continueWidget,
|
HomePage.routeName: continueWidget,
|
||||||
PrivacyPage.routeName: (BuildContext context) => PrivacyPage(context),
|
PrivacyPage.routeName: (BuildContext context) => PrivacyPage(context),
|
||||||
ActiveFiresPage.routeName: (BuildContext context) => const ActiveFiresPage(),
|
ActiveFiresPage.routeName: (BuildContext context) =>
|
||||||
|
const ActiveFiresPage(),
|
||||||
Sandbox.routeName: (BuildContext context) => const Sandbox(),
|
Sandbox.routeName: (BuildContext context) => const Sandbox(),
|
||||||
FireAlert.routeName: (BuildContext context) => const FireAlert(),
|
FireAlert.routeName: (BuildContext context) => const FireAlert(),
|
||||||
SupportPage.routeName: (BuildContext context) => const SupportPage(),
|
SupportPage.routeName: (BuildContext context) => const SupportPage(),
|
||||||
|
|
@ -51,8 +57,6 @@ class _FiresAppState extends State<FiresApp> {
|
||||||
const MonitoredAreasPage()
|
const MonitoredAreasPage()
|
||||||
};
|
};
|
||||||
|
|
||||||
final Store<AppState> store;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final StatefulWidget home = MaterialAppWithIntroHome(
|
final StatefulWidget home = MaterialAppWithIntroHome(
|
||||||
|
|
@ -61,7 +65,7 @@ class _FiresAppState extends State<FiresApp> {
|
||||||
store: store,
|
store: store,
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
localizationsDelegates: const <LocalizationsDelegate<dynamic>> [
|
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
|
||||||
S.delegate,
|
S.delegate,
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,19 @@ class GlobalFiresBottomStats extends StatefulWidget {
|
||||||
class _GlobalFiresBottomStatsState extends State<GlobalFiresBottomStats> {
|
class _GlobalFiresBottomStatsState extends State<GlobalFiresBottomStats> {
|
||||||
late String lastCheck;
|
late String lastCheck;
|
||||||
int activeFires = 0;
|
int activeFires = 0;
|
||||||
final String firesApiUrl = GetIt.instance<String>(instanceName: 'firesApiUrl');
|
final String firesApiUrl =
|
||||||
|
GetIt.instance<String>(instanceName: 'firesApiUrl');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
http.read(Uri.parse('${firesApiUrl}status/last-fire-check')).then((String result) {
|
http
|
||||||
|
.read(Uri.parse('${firesApiUrl}status/last-fire-check'))
|
||||||
|
.then((String result) {
|
||||||
try {
|
try {
|
||||||
final Moment now = Moment.now();
|
final Moment now = Moment.now();
|
||||||
final DateTime last = DateTime.parse(json.decode(result)['value'] as String);
|
final DateTime last =
|
||||||
|
DateTime.parse(json.decode(result)['value'] as String);
|
||||||
http
|
http
|
||||||
.read(Uri.parse('${firesApiUrl}status/active-fires-count'))
|
.read(Uri.parse('${firesApiUrl}status/active-fires-count'))
|
||||||
.then((String result) {
|
.then((String result) {
|
||||||
|
|
@ -37,10 +41,12 @@ class _GlobalFiresBottomStatsState extends State<GlobalFiresBottomStats> {
|
||||||
lastCheck = now.from(context, last);
|
lastCheck = now.from(context, last);
|
||||||
activeFires = count;
|
activeFires = count;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Ignore storage read errors
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -49,11 +55,9 @@ class _GlobalFiresBottomStatsState extends State<GlobalFiresBottomStats> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final List<Widget> actionWidgets = <Widget>[];
|
final List<Widget> actionWidgets = <Widget>[];
|
||||||
if (activeFires > 0) {
|
if (activeFires > 0) {
|
||||||
actionWidgets.add(Column(
|
actionWidgets
|
||||||
mainAxisSize: MainAxisSize.min,
|
.add(Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||||
children: <Widget>[
|
Text(S.of(context).activeFiresWorldWide(activeFires.toString())),
|
||||||
Text(
|
|
||||||
S.of(context).activeFiresWorldWide(activeFires.toString())),
|
|
||||||
Text(S.of(context).updatedLastCheck(lastCheck))
|
Text(S.of(context).updatedLastCheck(lastCheck))
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ Future<YourLocation> getUserLocation(
|
||||||
address =
|
address =
|
||||||
await getReverseLocation(lat: yl.lat, lon: yl.lon, external: true);
|
await getReverseLocation(lat: yl.lat, lon: yl.lon, external: true);
|
||||||
yl.description = address;
|
yl.description = address;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
|
// Ignore - fallback already attempted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return yl;
|
return yl;
|
||||||
|
|
@ -40,7 +41,9 @@ Future<YourLocation> getUserLocation(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
content: Text(S.of(context).notPermsUbication),
|
content: Text(S.of(context).notPermsUbication),
|
||||||
));
|
));
|
||||||
} else if (e.code == 'PERMISSION_DENIED_NEVER_ASK') {}
|
} else if (e.code == 'PERMISSION_DENIED_NEVER_ASK') {
|
||||||
|
// User selected "Don't ask again" - show settings prompt
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
content: Text(S.of(context).isYourUbicationEnabled),
|
content: Text(S.of(context).isYourUbicationEnabled),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,24 @@ abstract class MarkdownPage extends StatefulWidget {
|
||||||
final String route;
|
final String route;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_MarkdownPageState createState() =>
|
_MarkdownPageState createState() => _MarkdownPageState();
|
||||||
_MarkdownPageState(title: title, file: file, route: route);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MarkdownPageState extends State<MarkdownPage> {
|
class _MarkdownPageState extends State<MarkdownPage> {
|
||||||
_MarkdownPageState(
|
_MarkdownPageState();
|
||||||
{required this.title, required this.file, required this.route});
|
late final String title;
|
||||||
final String title;
|
late final String route;
|
||||||
final String route;
|
late final Future<String> file;
|
||||||
final Future<String> file;
|
|
||||||
String pageData = '';
|
String pageData = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
title = widget.title;
|
||||||
|
route = widget.route;
|
||||||
|
file = widget.file;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
file.then((String fileSync) {
|
file.then((String fileSync) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export 'fireMapState.dart';
|
||||||
part 'appState.g.dart';
|
part 'appState.g.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
@JsonSerializable(nullable: false)
|
@JsonSerializable()
|
||||||
class AppState {
|
class AppState {
|
||||||
const AppState(
|
const AppState(
|
||||||
{this.yourLocations = const <YourLocation>[],
|
{this.yourLocations = const <YourLocation>[],
|
||||||
|
|
@ -37,27 +37,27 @@ class AppState {
|
||||||
factory AppState.fromJson(Map<String, dynamic> json) =>
|
factory AppState.fromJson(Map<String, dynamic> json) =>
|
||||||
_$AppStateFromJson(json);
|
_$AppStateFromJson(json);
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final bool isLoaded;
|
final bool isLoaded;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String error;
|
final String error;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final User user;
|
final User user;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String gmapKey;
|
final String gmapKey;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String serverUrl;
|
final String serverUrl;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String firesApiKey;
|
final String firesApiKey;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String firesApiUrl;
|
final String firesApiUrl;
|
||||||
final List<YourLocation> yourLocations;
|
final List<YourLocation> yourLocations;
|
||||||
final List<FireNotification> fireNotifications;
|
final List<FireNotification> fireNotifications;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final List<Polyline> monitoredAreas;
|
final List<Polyline> monitoredAreas;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final int fireNotificationsUnread;
|
final int fireNotificationsUnread;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final FireMapState fireMapState;
|
final FireMapState fireMapState;
|
||||||
|
|
||||||
AppState copyWith(
|
AppState copyWith(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
part of 'appState.dart';
|
part of 'appState.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
part of 'fireNotification.dart';
|
part of 'fireNotification.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ class FiresApi {
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return response.data['data']['userId'] as String;
|
return response.data['data']['userId'] as String;
|
||||||
} else {
|
} else {
|
||||||
throw 'Unexpected error on create user';
|
throw Exception('Unexpected error on create user');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Error creating user: $e';
|
throw Exception('Error creating user: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,10 +64,10 @@ class FiresApi {
|
||||||
}
|
}
|
||||||
return subscribed;
|
return subscribed;
|
||||||
} else {
|
} else {
|
||||||
throw 'Unexpected error fetching your locations';
|
throw Exception('Unexpected error fetching your locations');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Error fetching locations: $e';
|
throw Exception('Error fetching locations: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,10 +86,10 @@ class FiresApi {
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return response.data['data']['subsId'] as String;
|
return response.data['data']['subsId'] as String;
|
||||||
} else {
|
} else {
|
||||||
throw 'Unexpected error on subscribe';
|
throw Exception('Unexpected error on subscribe');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Error subscribing: $e';
|
throw Exception('Error subscribing: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,10 +103,10 @@ class FiresApi {
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw 'Unexpected error on unsubscribe';
|
throw Exception('Unexpected error on unsubscribe');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Error unsubscribing: $e';
|
throw Exception('Error unsubscribing: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import '../objectIdUtils.dart';
|
||||||
|
|
||||||
part 'yourLocation.g.dart';
|
part 'yourLocation.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(nullable: false)
|
@JsonSerializable()
|
||||||
class YourLocation {
|
class YourLocation {
|
||||||
YourLocation(
|
YourLocation(
|
||||||
{required this.id,
|
{required this.id,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
part of 'yourLocation.dart';
|
part of 'yourLocation.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,26 +7,29 @@ import 'generated/i18n.dart';
|
||||||
typedef SlideCallback = void Function(int distance);
|
typedef SlideCallback = void Function(int distance);
|
||||||
|
|
||||||
class FireDistanceSlider extends StatefulWidget {
|
class FireDistanceSlider extends StatefulWidget {
|
||||||
|
const FireDistanceSlider(
|
||||||
const FireDistanceSlider({super.key, required this.initialValue, required this.onSlide});
|
{super.key, required this.initialValue, required this.onSlide});
|
||||||
final int initialValue;
|
final int initialValue;
|
||||||
final SlideCallback onSlide;
|
final SlideCallback onSlide;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_FireDistanceSliderState createState() => _FireDistanceSliderState(
|
_FireDistanceSliderState createState() => _FireDistanceSliderState();
|
||||||
initialValue: initialValue, onSlide: onSlide);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FireDistanceSliderState extends State<FireDistanceSlider> {
|
class _FireDistanceSliderState extends State<FireDistanceSlider> {
|
||||||
|
_FireDistanceSliderState();
|
||||||
_FireDistanceSliderState({int initialValue = 10, required this.onSlide}) {
|
|
||||||
_sliderValue = initialValue;
|
|
||||||
}
|
|
||||||
late int _sliderValue;
|
late int _sliderValue;
|
||||||
final SlideCallback onSlide;
|
late final SlideCallback onSlide;
|
||||||
|
|
||||||
Widget sizeText(int sliderValue) => Text(
|
@override
|
||||||
S.of(context).subscribeToValueAroundThisArea(sliderValue.toString()),
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_sliderValue = widget.initialValue;
|
||||||
|
onSlide = widget.onSlide;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget sizeText(int sliderValue) =>
|
||||||
|
Text(S.of(context).subscribeToValueAroundThisArea(sliderValue.toString()),
|
||||||
style: const TextStyle(color: Colors.black87));
|
style: const TextStyle(color: Colors.black87));
|
||||||
|
|
||||||
Widget warningText(int sliderValue) => _sliderValue >= 50
|
Widget warningText(int sliderValue) => _sliderValue >= 50
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue