From bbb8e342f10682766e744a789c9c878a358ea216 Mon Sep 17 00:00:00 2001 From: Abdullah Deshmukh Date: Thu, 5 Aug 2021 03:48:22 +0530 Subject: [PATCH] [linting_tool] Implement saving rules to DB (#860) --- .../linting_tool/analysis_options.yaml | 1 + experimental/linting_tool/lib/app.dart | 13 +- experimental/linting_tool/lib/main.dart | 9 +- .../linting_tool/lib/model/profile.dart | 26 +++ .../linting_tool/lib/model/profile.g.dart | 44 +++++ .../lib/model/profiles_store.dart | 69 +++++++ experimental/linting_tool/lib/model/rule.dart | 9 + .../linting_tool/lib/model/rule.g.dart | 56 ++++++ .../linting_tool/lib/model/rules_store.dart | 8 +- .../linting_tool/lib/pages/rules_page.dart | 74 ++++++++ .../lib/pages/saved_lints_page.dart | 115 +++++++++++- .../lib/repository/api_provider.dart | 5 +- .../lib/repository/hive_service.dart | 77 ++++++++ .../lib/repository/repository.dart | 7 +- .../lib/widgets/adaptive_nav.dart | 12 ++ .../lib/widgets/lint_expansion_tile.dart | 171 +++++++++++++++++- .../lib/widgets/saved_rule_tile.dart | 132 ++++++++++++++ .../macos/Runner/Base.lproj/MainMenu.xib | 13 +- experimental/linting_tool/pubspec.lock | 35 ++++ experimental/linting_tool/pubspec.yaml | 4 + .../linting_tool/test/widget_test.dart | 82 ++++++++- .../linting_tool/test/widget_test.mocks.dart | 107 +++++++++++ 22 files changed, 1045 insertions(+), 24 deletions(-) create mode 100644 experimental/linting_tool/lib/model/profile.dart create mode 100644 experimental/linting_tool/lib/model/profile.g.dart create mode 100644 experimental/linting_tool/lib/model/profiles_store.dart create mode 100644 experimental/linting_tool/lib/pages/rules_page.dart create mode 100644 experimental/linting_tool/lib/repository/hive_service.dart create mode 100644 experimental/linting_tool/lib/widgets/saved_rule_tile.dart create mode 100644 experimental/linting_tool/test/widget_test.mocks.dart diff --git a/experimental/linting_tool/analysis_options.yaml b/experimental/linting_tool/analysis_options.yaml index dd6b8cc01..5600d266e 100644 --- a/experimental/linting_tool/analysis_options.yaml +++ b/experimental/linting_tool/analysis_options.yaml @@ -3,6 +3,7 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - lib/model/rule.g.dart + - test/widget_test.mocks.dart strong-mode: implicit-casts: false implicit-dynamic: false diff --git a/experimental/linting_tool/lib/app.dart b/experimental/linting_tool/lib/app.dart index 46293d58a..7b31b4a5e 100644 --- a/experimental/linting_tool/lib/app.dart +++ b/experimental/linting_tool/lib/app.dart @@ -3,11 +3,13 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:linting_tool/model/profiles_store.dart'; import 'package:linting_tool/model/rules_store.dart'; import 'package:linting_tool/theme/app_theme.dart'; import 'package:linting_tool/widgets/adaptive_nav.dart'; import 'package:linting_tool/routes.dart' as routes; import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; class LintingTool extends StatefulWidget { const LintingTool({Key? key}) : super(key: key); @@ -21,8 +23,15 @@ class LintingTool extends StatefulWidget { class _LintingToolState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (context) => RuleStore(), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => RuleStore(http.Client()), + ), + ChangeNotifierProvider( + create: (context) => ProfilesStore(), + ), + ], child: MaterialApp( title: 'Flutter Linting Tool', theme: AppTheme.buildReplyLightTheme(context), diff --git a/experimental/linting_tool/lib/main.dart b/experimental/linting_tool/lib/main.dart index 84c7024e2..e9e6543ed 100644 --- a/experimental/linting_tool/lib/main.dart +++ b/experimental/linting_tool/lib/main.dart @@ -3,8 +3,15 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:linting_tool/app.dart'; +import 'package:linting_tool/model/profile.dart'; +import 'package:linting_tool/model/rule.dart'; -void main() { +Future main() async { + await Hive.initFlutter(); + Hive.registerAdapter(RuleAdapter()); + Hive.registerAdapter(RulesProfileAdapter()); + await Hive.openLazyBox('rules_profile'); runApp(const LintingTool()); } diff --git a/experimental/linting_tool/lib/model/profile.dart b/experimental/linting_tool/lib/model/profile.dart new file mode 100644 index 000000000..c9acc51a0 --- /dev/null +++ b/experimental/linting_tool/lib/model/profile.dart @@ -0,0 +1,26 @@ +// Copyright 2021 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; +import 'package:linting_tool/model/rule.dart'; + +part 'profile.g.dart'; + +@HiveType(typeId: 1) +class RulesProfile extends Equatable { + @HiveField(0) + final String name; + + @HiveField(1) + final List rules; + + const RulesProfile({ + required this.name, + required this.rules, + }); + + @override + List get props => [name]; +} diff --git a/experimental/linting_tool/lib/model/profile.g.dart b/experimental/linting_tool/lib/model/profile.g.dart new file mode 100644 index 000000000..85d776f09 --- /dev/null +++ b/experimental/linting_tool/lib/model/profile.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'profile.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class RulesProfileAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + RulesProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return RulesProfile( + name: fields[0] as String, + rules: (fields[1] as List).cast(), + ); + } + + @override + void write(BinaryWriter writer, RulesProfile obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.rules); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RulesProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/experimental/linting_tool/lib/model/profiles_store.dart b/experimental/linting_tool/lib/model/profiles_store.dart new file mode 100644 index 000000000..fd8d48dd2 --- /dev/null +++ b/experimental/linting_tool/lib/model/profiles_store.dart @@ -0,0 +1,69 @@ +// Copyright 2021 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:developer'; +import 'package:flutter/material.dart'; +import 'package:linting_tool/model/profile.dart'; +import 'package:linting_tool/model/rule.dart'; +import 'package:linting_tool/repository/hive_service.dart'; + +const _boxName = 'rules_profile'; + +class ProfilesStore extends ChangeNotifier { + ProfilesStore() { + fetchSavedProfiles(); + } + bool _isLoading = true; + + bool get isLoading => _isLoading; + + List _savedProfiles = []; + + List get savedProfiles => _savedProfiles; + + String? _error; + + String? get error => _error; + + Future fetchSavedProfiles() async { + if (!_isLoading) _isLoading = true; + notifyListeners(); + try { + var profiles = await HiveService.getBoxes(_boxName); + _savedProfiles = profiles; + } on Exception catch (e) { + log(e.toString()); + } + _isLoading = false; + + notifyListeners(); + } + + Future addToNewProfile(RulesProfile profile) async { + await HiveService.addBox(profile, _boxName); + + await Future.delayed(const Duration(milliseconds: 100), () async { + await fetchSavedProfiles(); + }); + } + + Future addToExistingProfile(RulesProfile profile, Rule rule) async { + RulesProfile newProfile = + RulesProfile(name: profile.name, rules: profile.rules..add(rule)); + + await HiveService.updateBox(profile, newProfile, _boxName); + + await Future.delayed(const Duration(milliseconds: 100), () async { + await fetchSavedProfiles(); + }); + } + + Future deleteProfile(RulesProfile profile) async { + await HiveService.deleteBox(profile, _boxName); + + await Future.delayed(const Duration(milliseconds: 100), () async { + await fetchSavedProfiles(); + }); + } +} diff --git a/experimental/linting_tool/lib/model/rule.dart b/experimental/linting_tool/lib/model/rule.dart index 482e05b55..5d9c5a103 100644 --- a/experimental/linting_tool/lib/model/rule.dart +++ b/experimental/linting_tool/lib/model/rule.dart @@ -3,18 +3,27 @@ // found in the LICENSE file. import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; part 'rule.g.dart'; @JsonSerializable() +@HiveType(typeId: 0) class Rule extends Equatable { + @HiveField(0) final String name; + @HiveField(1) final String description; + @HiveField(2) final String group; + @HiveField(3) final String maturity; + @HiveField(4) final List incompatible; + @HiveField(5) final List sets; + @HiveField(6) final String details; const Rule({ diff --git a/experimental/linting_tool/lib/model/rule.g.dart b/experimental/linting_tool/lib/model/rule.g.dart index 4e4983628..fdcd5f5ea 100644 --- a/experimental/linting_tool/lib/model/rule.g.dart +++ b/experimental/linting_tool/lib/model/rule.g.dart @@ -2,6 +2,62 @@ part of 'rule.dart'; +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class RuleAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + Rule read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Rule( + name: fields[0] as String, + description: fields[1] as String, + group: fields[2] as String, + maturity: fields[3] as String, + incompatible: (fields[4] as List).cast(), + sets: (fields[5] as List).cast(), + details: fields[6] as String, + ); + } + + @override + void write(BinaryWriter writer, Rule obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.description) + ..writeByte(2) + ..write(obj.group) + ..writeByte(3) + ..write(obj.maturity) + ..writeByte(4) + ..write(obj.incompatible) + ..writeByte(5) + ..write(obj.sets) + ..writeByte(6) + ..write(obj.details); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RuleAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** diff --git a/experimental/linting_tool/lib/model/rules_store.dart b/experimental/linting_tool/lib/model/rules_store.dart index 786bfb44f..d1b145b1d 100644 --- a/experimental/linting_tool/lib/model/rules_store.dart +++ b/experimental/linting_tool/lib/model/rules_store.dart @@ -8,9 +8,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:linting_tool/model/rule.dart'; import 'package:linting_tool/repository/repository.dart'; +import 'package:http/http.dart' as http; class RuleStore extends ChangeNotifier { - RuleStore() { + late final Repository repository; + + RuleStore(http.Client httpClient) { + repository = Repository(httpClient); fetchRules(); } bool _isLoading = true; @@ -29,7 +33,7 @@ class RuleStore extends ChangeNotifier { if (!_isLoading) _isLoading = true; notifyListeners(); try { - var rules = await Repository().getRulesList(); + var rules = await repository.getRulesList(); _rules = rules; } on SocketException catch (e) { log(e.toString()); diff --git a/experimental/linting_tool/lib/pages/rules_page.dart b/experimental/linting_tool/lib/pages/rules_page.dart new file mode 100644 index 000000000..0f9138678 --- /dev/null +++ b/experimental/linting_tool/lib/pages/rules_page.dart @@ -0,0 +1,74 @@ +// Copyright 2021 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:linting_tool/layout/adaptive.dart'; +import 'package:linting_tool/model/profile.dart'; +import 'package:linting_tool/widgets/saved_rule_tile.dart'; + +class RulesPage extends StatelessWidget { + final RulesProfile profile; + + const RulesPage({ + required this.profile, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isDesktop = isDisplayLarge(context); + final isTablet = isDisplayMedium(context); + var textTheme = Theme.of(context).textTheme; + final startPadding = isTablet + ? 60.0 + : isDesktop + ? 120.0 + : 4.0; + final endPadding = isTablet + ? 60.0 + : isDesktop + ? 120.0 + : 4.0; + return Scaffold( + appBar: AppBar( + title: Text( + profile.name, + style: textTheme.subtitle2!.copyWith( + color: textTheme.bodyText1!.color, + ), + ), + leading: Padding( + padding: const EdgeInsets.only(left: 80.0), + child: TextButton.icon( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon(Icons.arrow_back_ios_new), + label: const Text('Back'), + ), + ), + leadingWidth: 160.0, + toolbarHeight: 38.0, + backgroundColor: Colors.white, + brightness: Brightness.light, + ), + body: ListView.separated( + padding: EdgeInsetsDirectional.only( + start: startPadding, + end: endPadding, + top: isDesktop ? 28 : 0, + bottom: isDesktop ? kToolbarHeight : 0, + ), + itemCount: profile.rules.length, + cacheExtent: 5, + itemBuilder: (context, index) { + return SavedRuleTile( + rule: profile.rules[index], + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 4), + ), + ); + } +} diff --git a/experimental/linting_tool/lib/pages/saved_lints_page.dart b/experimental/linting_tool/lib/pages/saved_lints_page.dart index dd1d93ea5..6c94b0a93 100644 --- a/experimental/linting_tool/lib/pages/saved_lints_page.dart +++ b/experimental/linting_tool/lib/pages/saved_lints_page.dart @@ -3,13 +3,124 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:linting_tool/layout/adaptive.dart'; +import 'package:linting_tool/model/profiles_store.dart'; +import 'package:linting_tool/pages/rules_page.dart'; +import 'package:linting_tool/theme/colors.dart'; +import 'package:provider/provider.dart'; class SavedLintsPage extends StatelessWidget { const SavedLintsPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - // TODO(abd99): Implement SavedLintsPage, showing a list of saved lint rules profiles. - return const Text('Saved Profiles'); + return Consumer( + builder: (context, profilesStore, child) { + if (profilesStore.isLoading) { + return const CircularProgressIndicator.adaptive(); + } + + if (!profilesStore.isLoading) { + if (profilesStore.savedProfiles.isNotEmpty) { + final isDesktop = isDisplayLarge(context); + final isTablet = isDisplayMedium(context); + final startPadding = isTablet + ? 60.0 + : isDesktop + ? 120.0 + : 4.0; + final endPadding = isTablet + ? 60.0 + : isDesktop + ? 120.0 + : 4.0; + + return ListView.separated( + padding: EdgeInsetsDirectional.only( + start: startPadding, + end: endPadding, + top: isDesktop ? 28 : 0, + bottom: isDesktop ? kToolbarHeight : 0, + ), + itemCount: profilesStore.savedProfiles.length, + cacheExtent: 5, + itemBuilder: (context, index) { + var profile = profilesStore.savedProfiles[index]; + return ListTile( + title: Text( + profile.name, + ), + tileColor: AppColors.white50, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RulesPage(profile: profile), + ), + ); + }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + // TODO(abd99): Implement edit functionality. + }, + ), + const SizedBox( + width: 8.0, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + switch (value) { + case 'Export file': + // TODO(abd99): Implement exporting files. + break; + case 'Delete': + profilesStore.deleteProfile(profile); + break; + default: + } + }, + itemBuilder: (context) { + return [ + const PopupMenuItem( + child: Text('Export file'), + value: 'Export file', + ), + const PopupMenuItem( + child: Text('Delete'), + value: 'Delete', + ), + ]; + }, + ), + ], + ), + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 4), + ); + } + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(profilesStore.error ?? 'No saved profiles found.'), + const SizedBox( + height: 16.0, + ), + IconButton( + onPressed: () => profilesStore.fetchSavedProfiles(), + icon: const Icon(Icons.refresh), + ), + ], + ); + }, + ); } } diff --git a/experimental/linting_tool/lib/repository/api_provider.dart b/experimental/linting_tool/lib/repository/api_provider.dart index 99335c1c9..8cead9f11 100644 --- a/experimental/linting_tool/lib/repository/api_provider.dart +++ b/experimental/linting_tool/lib/repository/api_provider.dart @@ -8,9 +8,12 @@ import 'package:linting_tool/model/rule.dart'; class APIProvider { final _baseURL = 'https://dart-lang.github.io/linter'; + final http.Client httpClient; + APIProvider(this.httpClient); + Future> getRulesList() async { http.Response response = - await http.get(Uri.parse('$_baseURL//lints/machine/rules.json')); + await httpClient.get(Uri.parse('$_baseURL//lints/machine/rules.json')); if (response.statusCode == 200) { List rulesList = []; diff --git a/experimental/linting_tool/lib/repository/hive_service.dart b/experimental/linting_tool/lib/repository/hive_service.dart new file mode 100644 index 000000000..0708f389c --- /dev/null +++ b/experimental/linting_tool/lib/repository/hive_service.dart @@ -0,0 +1,77 @@ +// Copyright 2021 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:hive/hive.dart'; + +class HiveService { + static Future addBox(T item, String boxName) async { + final openBox = await Hive.openLazyBox( + boxName, + ); + + List existingProducts = await getBoxes(boxName); + + if (!existingProducts.contains(item)) { + openBox.add(item); + return true; + } + return false; + } + + static Future addBoxes(List items, String boxName) async { + final openBox = await Hive.openLazyBox( + boxName, + ); + + List existingProducts = await getBoxes(boxName); + + for (var item in items) { + if (!existingProducts.contains(item)) { + openBox.add(item); + } + } + } + + static Future deleteBox(T item, String boxName) async { + final openBox = await Hive.openLazyBox( + boxName, + ); + + List boxes = await getBoxes(boxName); + + for (var box in boxes) { + if (box == item) { + openBox.deleteAt(boxes.indexOf(item)); + } + } + } + + static Future updateBox(T item, T newItem, String boxName) async { + final openBox = await Hive.openLazyBox( + boxName, + ); + + List boxes = await getBoxes(boxName); + + for (var box in boxes) { + if (box == item) { + openBox.putAt(boxes.indexOf(item), newItem); + } + } + } + + static Future> getBoxes(String boxName, [String? query]) async { + List boxList = []; + + final openBox = await Hive.openLazyBox(boxName); + + int length = openBox.length; + + for (int i = 0; i < length; i++) { + boxList.add(await openBox.getAt(i) as T); + } + + return boxList; + } +} diff --git a/experimental/linting_tool/lib/repository/repository.dart b/experimental/linting_tool/lib/repository/repository.dart index 0d3a6a8b1..4e17d4984 100644 --- a/experimental/linting_tool/lib/repository/repository.dart +++ b/experimental/linting_tool/lib/repository/repository.dart @@ -4,9 +4,14 @@ import 'package:linting_tool/model/rule.dart'; import 'package:linting_tool/repository/api_provider.dart'; +import 'package:http/http.dart' as http; class Repository { - final _apiProvider = APIProvider(); + late final APIProvider _apiProvider; + + Repository(http.Client httpClient) { + _apiProvider = APIProvider(httpClient); + } Future> getRulesList() => _apiProvider.getRulesList(); } diff --git a/experimental/linting_tool/lib/widgets/adaptive_nav.dart b/experimental/linting_tool/lib/widgets/adaptive_nav.dart index a143030e0..a1da02d51 100644 --- a/experimental/linting_tool/lib/widgets/adaptive_nav.dart +++ b/experimental/linting_tool/lib/widgets/adaptive_nav.dart @@ -91,7 +91,19 @@ class _NavViewState extends State<_NavView> { @override Widget build(BuildContext context) { + var textTheme = Theme.of(context).textTheme; return Scaffold( + appBar: AppBar( + title: Text( + 'Flutter Linting Tool', + style: textTheme.subtitle2!.copyWith( + color: textTheme.bodyText1!.color, + ), + ), + toolbarHeight: 38.0, + backgroundColor: Colors.white, + brightness: Brightness.light, + ), body: Row( children: [ LayoutBuilder( diff --git a/experimental/linting_tool/lib/widgets/lint_expansion_tile.dart b/experimental/linting_tool/lib/widgets/lint_expansion_tile.dart index 7bcdf0ff7..206fa8ac3 100644 --- a/experimental/linting_tool/lib/widgets/lint_expansion_tile.dart +++ b/experimental/linting_tool/lib/widgets/lint_expansion_tile.dart @@ -4,9 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:linting_tool/model/profile.dart'; +import 'package:linting_tool/model/profiles_store.dart'; import 'package:linting_tool/model/rule.dart'; import 'package:linting_tool/theme/app_theme.dart'; import 'package:linting_tool/theme/colors.dart'; +import 'package:provider/provider.dart'; class LintExpansionTile extends StatefulWidget { final Rule rule; @@ -127,8 +130,30 @@ class _LintExpansionTileState extends State { alignment: Alignment.centerRight, child: ElevatedButton( child: const Text('Add to profile'), - onPressed: () { - // TODO(abd99): Iplement adding to a profile. + onPressed: () async { + ProfileType? destinationProfileType = + await showDialog( + context: context, + builder: (context) { + return const _ProfileTypeDialog(); + }, + ); + if (destinationProfileType == ProfileType.newProfile) { + showDialog( + context: context, + builder: (context) { + return _NewProfileDialog(rule: rule); + }, + ); + } else if (destinationProfileType == + ProfileType.existingProfile) { + showDialog( + context: context, + builder: (context) { + return _ExistingProfileDialog(rule: rule); + }, + ); + } }, ), ), @@ -139,3 +164,145 @@ class _LintExpansionTileState extends State { ); } } + +enum ProfileType { + newProfile, + existingProfile, +} + +class _ProfileTypeDialog extends StatelessWidget { + const _ProfileTypeDialog({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + actionsPadding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 16.0, + ), + title: const Text('Select Profile Type'), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context, ProfileType.existingProfile); + }, + child: const Text('Existing Profile'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, ProfileType.newProfile); + }, + child: const Text('Create new profile'), + ), + ], + ); + } +} + +class _NewProfileDialog extends StatelessWidget { + final Rule rule; + const _NewProfileDialog({ + required this.rule, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + String name = ''; + final _formKey = GlobalKey(); + + return AlertDialog( + title: const Text('Create new lint profile'), + content: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Profile Name'), + TextFormField( + onChanged: (value) { + name = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Name cannot be empty.'; + } + return null; + }, + ), + ], + ), + ), + actionsPadding: const EdgeInsets.all(16.0), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + if (_formKey.currentState!.validate()) { + var newProfile = RulesProfile( + name: name, + rules: [rule], + ); + await Provider.of(context, listen: false) + .addToNewProfile(newProfile); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _ExistingProfileDialog extends StatelessWidget { + const _ExistingProfileDialog({ + Key? key, + required this.rule, + }) : super(key: key); + + final Rule rule; + + @override + Widget build(BuildContext context) { + var profilesStore = Provider.of(context); + var savedProfiles = profilesStore.savedProfiles; + return AlertDialog( + title: const Text('Select a lint profile'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate( + savedProfiles.length, + (index) => ListTile( + title: Text(savedProfiles[index].name), + onTap: () async { + await profilesStore.addToExistingProfile( + savedProfiles[index], rule); + Navigator.pop(context); + }, + ), + ), + ), + actionsPadding: const EdgeInsets.all(16.0), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Cancel'), + ), + ], + ); + } +} diff --git a/experimental/linting_tool/lib/widgets/saved_rule_tile.dart b/experimental/linting_tool/lib/widgets/saved_rule_tile.dart new file mode 100644 index 000000000..b8e333d0a --- /dev/null +++ b/experimental/linting_tool/lib/widgets/saved_rule_tile.dart @@ -0,0 +1,132 @@ +// Copyright 2021 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:linting_tool/model/rule.dart'; +import 'package:linting_tool/theme/app_theme.dart'; +import 'package:linting_tool/theme/colors.dart'; + +class SavedRuleTile extends StatefulWidget { + final Rule rule; + const SavedRuleTile({ + required this.rule, + Key? key, + }) : super(key: key); + + @override + _SavedRuleTileState createState() => _SavedRuleTileState(); +} + +class _SavedRuleTileState extends State { + var isExpanded = false; + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textTheme = theme.textTheme; + final rule = widget.rule; + final incompatibleString = + rule.incompatible.isNotEmpty ? rule.incompatible.join(', ') : 'none'; + final setsString = rule.sets.isNotEmpty ? rule.sets.join(', ') : 'none'; + + // TODO(abd99): Add option to remove rule from profile. + // TODO(abd99): Add right click functionality. + return ExpansionTile( + collapsedBackgroundColor: AppColors.white50, + title: Text( + rule.name, + style: textTheme.subtitle1!.copyWith( + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + rule.description, + style: textTheme.caption!, + ), + initiallyExpanded: isExpanded, + onExpansionChanged: (value) { + setState(() { + isExpanded = value; + }); + }, + expandedAlignment: Alignment.centerLeft, + childrenPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + backgroundColor: AppColors.white50, + maintainState: true, + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Group:', + style: textTheme.subtitle2, + ), + TextSpan( + text: ' ${rule.group}', + ), + ], + ), + textAlign: TextAlign.left, + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Maturity:', + style: textTheme.subtitle2, + ), + TextSpan( + text: ' ${rule.maturity}', + ), + ], + ), + textAlign: TextAlign.left, + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Incompatible:', + style: textTheme.subtitle2, + ), + TextSpan( + text: ' $incompatibleString', + ), + ], + ), + textAlign: TextAlign.left, + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Sets:', + style: textTheme.subtitle2, + ), + TextSpan( + text: ' $setsString', + ), + ], + ), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 16.0, + ), + MarkdownBody( + data: rule.details, + selectable: true, + styleSheet: AppTheme.buildMarkDownTheme(theme), + ), + const SizedBox( + height: 16.0, + ), + ], + ); + } +} diff --git a/experimental/linting_tool/macos/Runner/Base.lproj/MainMenu.xib b/experimental/linting_tool/macos/Runner/Base.lproj/MainMenu.xib index 537341abf..d196364b9 100644 --- a/experimental/linting_tool/macos/Runner/Base.lproj/MainMenu.xib +++ b/experimental/linting_tool/macos/Runner/Base.lproj/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -13,7 +13,7 @@ - + @@ -326,14 +326,15 @@ - - + + - + + diff --git a/experimental/linting_tool/pubspec.lock b/experimental/linting_tool/pubspec.lock index e4beb7282..2d0de87d7 100644 --- a/experimental/linting_tool/pubspec.lock +++ b/experimental/linting_tool/pubspec.lock @@ -263,6 +263,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + hive: + dependency: "direct main" + description: + name: hive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" http: dependency: "direct main" description: @@ -354,6 +375,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + mockito: + dependency: "direct main" + description: + name: mockito + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.13" nested: dependency: transitive description: @@ -492,6 +520,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" source_span: dependency: transitive description: diff --git a/experimental/linting_tool/pubspec.yaml b/experimental/linting_tool/pubspec.yaml index f00c9d5d9..f789abede 100644 --- a/experimental/linting_tool/pubspec.yaml +++ b/experimental/linting_tool/pubspec.yaml @@ -14,8 +14,11 @@ dependencies: equatable: ^2.0.3 flutter_markdown: ^0.6.2 google_fonts: ^2.1.0 + hive: ^2.0.4 + hive_flutter: ^1.1.0 http: ^0.13.3 json_annotation: ^4.0.1 + mockito: ^5.0.13 provider: ^5.0.0 dev_dependencies: @@ -23,6 +26,7 @@ dev_dependencies: sdk: flutter build_runner: ^2.0.6 flutter_lints: ^1.0.3 + hive_generator: ^1.1.0 json_serializable: ^4.1.4 flutter: diff --git a/experimental/linting_tool/test/widget_test.dart b/experimental/linting_tool/test/widget_test.dart index ca2c76550..4b4e1c879 100644 --- a/experimental/linting_tool/test/widget_test.dart +++ b/experimental/linting_tool/test/widget_test.dart @@ -1,20 +1,88 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// Copyright 2021 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'dart:io'; + +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:linting_tool/app.dart'; +import 'package:linting_tool/model/profile.dart'; +import 'package:linting_tool/model/profiles_store.dart'; +import 'package:linting_tool/model/rule.dart'; +import 'package:linting_tool/model/rules_store.dart'; import 'package:linting_tool/pages/default_lints_page.dart'; import 'package:linting_tool/pages/home_page.dart'; import 'package:linting_tool/pages/saved_lints_page.dart'; +import 'package:linting_tool/theme/app_theme.dart'; +import 'package:linting_tool/widgets/adaptive_nav.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; +import 'widget_test.mocks.dart'; + +late MockClient _mockClient; + +class _TestApp extends StatelessWidget { + const _TestApp({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => RuleStore(_mockClient), + ), + ChangeNotifierProvider( + create: (context) => ProfilesStore(), + ), + ], + child: MaterialApp( + title: 'Flutter Linting Tool', + initialRoute: LintingTool.homeRoute, + theme: AppTheme.buildReplyLightTheme(context), + onGenerateRoute: (settings) { + switch (settings.name) { + case LintingTool.homeRoute: + return MaterialPageRoute( + builder: (context) => const AdaptiveNav(), + settings: settings, + ); + } + return null; + }, + ), + ); + } +} +@GenerateMocks([http.Client]) void main() { + setUp(() async { + final tempDir = await Directory.systemTemp.createTemp(); + Hive.init(tempDir.path); + Hive.registerAdapter(RuleAdapter()); + Hive.registerAdapter(RulesProfileAdapter()); + await Hive.openLazyBox('rules_profile'); + _mockClient = MockClient(); + }); testWidgets('NavigationRail smoke test', (tester) async { - await tester.pumpWidget(const LintingTool()); + var responseBody = + '''[{"name": "always_use_package_imports","description": "Avoid relative imports for files in `lib/`.","group": "errors","maturity": "stable","incompatible": [],"sets": [],"details": "*DO* avoid relative imports for files in `lib/`.\n\nWhen mixing relative and absolute imports it's possible to create confusion\nwhere the same member gets imported in two different ways. One way to avoid\nthat is to ensure you consistently use absolute imports for files withing the\n`lib/` directory.\n\nThis is the opposite of 'prefer_relative_imports'.\nMight be used with 'avoid_relative_lib_imports' to avoid relative imports of\nfiles within `lib/` directory outside of it. (for example `test/`)\n\n**GOOD:**\n\n```dart\nimport 'package:foo/bar.dart';\n\nimport 'package:foo/baz.dart';\n\nimport 'package:foo/src/baz.dart';\n...\n```\n\n**BAD:**\n\n```dart\nimport 'baz.dart';\n\nimport 'src/bag.dart'\n\nimport '../lib/baz.dart';\n\n...\n```\n\n"}]'''; + + when(_mockClient.get(Uri.parse( + 'https://dart-lang.github.io/linter//lints/machine/rules.json'))) + .thenAnswer( + (_) async => http.Response(responseBody, 400), + ); + await tester.pumpWidget(const _TestApp()); + + expect(find.byType(HomePage), findsOneWidget); expect(find.byType(SavedLintsPage), findsNothing); await tester.tap(find.text('Saved Profiles')); await tester.pumpAndSettle(); diff --git a/experimental/linting_tool/test/widget_test.mocks.dart b/experimental/linting_tool/test/widget_test.mocks.dart new file mode 100644 index 000000000..98de493cc --- /dev/null +++ b/experimental/linting_tool/test/widget_test.mocks.dart @@ -0,0 +1,107 @@ +// Mocks generated by Mockito 5.0.13 from annotations +// in linting_tool/test/widget_test.dart. +// Do not manually edit this file. + +import 'dart:async' as _i5; +import 'dart:convert' as _i6; +import 'dart:typed_data' as _i7; + +import 'package:http/src/base_request.dart' as _i8; +import 'package:http/src/client.dart' as _i4; +import 'package:http/src/response.dart' as _i2; +import 'package:http/src/streamed_response.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis + +class _FakeResponse extends _i1.Fake implements _i2.Response {} + +class _FakeStreamedResponse extends _i1.Fake implements _i3.StreamedResponse {} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i4.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i2.Response> head(Uri? url, {Map? headers}) => + (super.noSuchMethod(Invocation.method(#head, [url], {#headers: headers}), + returnValue: Future<_i2.Response>.value(_FakeResponse())) + as _i5.Future<_i2.Response>); + @override + _i5.Future<_i2.Response> get(Uri? url, {Map? headers}) => + (super.noSuchMethod(Invocation.method(#get, [url], {#headers: headers}), + returnValue: Future<_i2.Response>.value(_FakeResponse())) + as _i5.Future<_i2.Response>); + @override + _i5.Future<_i2.Response> post(Uri? url, + {Map? headers, + Object? body, + _i6.Encoding? encoding}) => + (super.noSuchMethod( + Invocation.method(#post, [url], + {#headers: headers, #body: body, #encoding: encoding}), + returnValue: Future<_i2.Response>.value(_FakeResponse())) + as _i5.Future<_i2.Response>); + @override + _i5.Future<_i2.Response> put(Uri? url, + {Map? headers, + Object? body, + _i6.Encoding? encoding}) => + (super.noSuchMethod( + Invocation.method(#put, [url], + {#headers: headers, #body: body, #encoding: encoding}), + returnValue: Future<_i2.Response>.value(_FakeResponse())) + as _i5.Future<_i2.Response>); + @override + _i5.Future<_i2.Response> patch(Uri? url, + {Map? headers, + Object? body, + _i6.Encoding? encoding}) => + (super.noSuchMethod( + Invocation.method(#patch, [url], + {#headers: headers, #body: body, #encoding: encoding}), + returnValue: Future<_i2.Response>.value(_FakeResponse())) + as _i5.Future<_i2.Response>); + @override + _i5.Future<_i2.Response> delete(Uri? url, + {Map? headers, + Object? body, + _i6.Encoding? encoding}) => + (super.noSuchMethod( + Invocation.method(#delete, [url], + {#headers: headers, #body: body, #encoding: encoding}), + returnValue: Future<_i2.Response>.value(_FakeResponse())) + as _i5.Future<_i2.Response>); + @override + _i5.Future read(Uri? url, {Map? headers}) => + (super.noSuchMethod(Invocation.method(#read, [url], {#headers: headers}), + returnValue: Future.value('')) as _i5.Future); + @override + _i5.Future<_i7.Uint8List> readBytes(Uri? url, + {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: Future<_i7.Uint8List>.value(_i7.Uint8List(0))) + as _i5.Future<_i7.Uint8List>); + @override + _i5.Future<_i3.StreamedResponse> send(_i8.BaseRequest? request) => + (super.noSuchMethod(Invocation.method(#send, [request]), + returnValue: + Future<_i3.StreamedResponse>.value(_FakeStreamedResponse())) + as _i5.Future<_i3.StreamedResponse>); + @override + void close() => super.noSuchMethod(Invocation.method(#close, []), + returnValueForMissingStub: null); + @override + String toString() => super.toString(); +}