1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +00:00

[linting_tool] Implement saving rules to DB (#860)

This commit is contained in:
Abdullah Deshmukh
2021-08-05 03:48:22 +05:30
committed by GitHub
parent 1818925286
commit bbb8e342f1
22 changed files with 1045 additions and 24 deletions

View File

@@ -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<LintingTool> {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<RuleStore>(
create: (context) => RuleStore(),
return MultiProvider(
providers: [
ChangeNotifierProvider<RuleStore>(
create: (context) => RuleStore(http.Client()),
),
ChangeNotifierProvider<ProfilesStore>(
create: (context) => ProfilesStore(),
),
],
child: MaterialApp(
title: 'Flutter Linting Tool',
theme: AppTheme.buildReplyLightTheme(context),

View File

@@ -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<void> main() async {
await Hive.initFlutter();
Hive.registerAdapter(RuleAdapter());
Hive.registerAdapter(RulesProfileAdapter());
await Hive.openLazyBox<RulesProfile>('rules_profile');
runApp(const LintingTool());
}

View File

@@ -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<Rule> rules;
const RulesProfile({
required this.name,
required this.rules,
});
@override
List<Object?> get props => [name];
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'profile.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class RulesProfileAdapter extends TypeAdapter<RulesProfile> {
@override
final int typeId = 1;
@override
RulesProfile read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return RulesProfile(
name: fields[0] as String,
rules: (fields[1] as List).cast<Rule>(),
);
}
@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;
}

View File

@@ -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<RulesProfile> _savedProfiles = [];
List<RulesProfile> get savedProfiles => _savedProfiles;
String? _error;
String? get error => _error;
Future<void> fetchSavedProfiles() async {
if (!_isLoading) _isLoading = true;
notifyListeners();
try {
var profiles = await HiveService.getBoxes<RulesProfile>(_boxName);
_savedProfiles = profiles;
} on Exception catch (e) {
log(e.toString());
}
_isLoading = false;
notifyListeners();
}
Future<void> addToNewProfile(RulesProfile profile) async {
await HiveService.addBox<RulesProfile>(profile, _boxName);
await Future.delayed(const Duration(milliseconds: 100), () async {
await fetchSavedProfiles();
});
}
Future<void> addToExistingProfile(RulesProfile profile, Rule rule) async {
RulesProfile newProfile =
RulesProfile(name: profile.name, rules: profile.rules..add(rule));
await HiveService.updateBox<RulesProfile>(profile, newProfile, _boxName);
await Future.delayed(const Duration(milliseconds: 100), () async {
await fetchSavedProfiles();
});
}
Future<void> deleteProfile(RulesProfile profile) async {
await HiveService.deleteBox<RulesProfile>(profile, _boxName);
await Future.delayed(const Duration(milliseconds: 100), () async {
await fetchSavedProfiles();
});
}
}

View File

@@ -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<String> incompatible;
@HiveField(5)
final List<String> sets;
@HiveField(6)
final String details;
const Rule({

View File

@@ -2,6 +2,62 @@
part of 'rule.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class RuleAdapter extends TypeAdapter<Rule> {
@override
final int typeId = 0;
@override
Rule read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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<String>(),
sets: (fields[5] as List).cast<String>(),
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
// **************************************************************************

View File

@@ -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());

View File

@@ -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),
),
);
}
}

View File

@@ -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<ProfilesStore>(
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<void>(
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<String>(
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),
),
],
);
},
);
}
}

View File

@@ -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<List<Rule>> 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<Rule> rulesList = [];

View File

@@ -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<bool> addBox<T>(T item, String boxName) async {
final openBox = await Hive.openLazyBox<T>(
boxName,
);
List<T> existingProducts = await getBoxes(boxName);
if (!existingProducts.contains(item)) {
openBox.add(item);
return true;
}
return false;
}
static Future addBoxes<T>(List<T> items, String boxName) async {
final openBox = await Hive.openLazyBox<T>(
boxName,
);
List<T> existingProducts = await getBoxes(boxName);
for (var item in items) {
if (!existingProducts.contains(item)) {
openBox.add(item);
}
}
}
static Future deleteBox<T>(T item, String boxName) async {
final openBox = await Hive.openLazyBox<T>(
boxName,
);
List<T> boxes = await getBoxes(boxName);
for (var box in boxes) {
if (box == item) {
openBox.deleteAt(boxes.indexOf(item));
}
}
}
static Future updateBox<T>(T item, T newItem, String boxName) async {
final openBox = await Hive.openLazyBox<T>(
boxName,
);
List<T> boxes = await getBoxes(boxName);
for (var box in boxes) {
if (box == item) {
openBox.putAt(boxes.indexOf(item), newItem);
}
}
}
static Future<List<T>> getBoxes<T>(String boxName, [String? query]) async {
List<T> boxList = [];
final openBox = await Hive.openLazyBox<T>(boxName);
int length = openBox.length;
for (int i = 0; i < length; i++) {
boxList.add(await openBox.getAt(i) as T);
}
return boxList;
}
}

View File

@@ -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<List<Rule>> getRulesList() => _apiProvider.getRulesList();
}

View File

@@ -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(

View File

@@ -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<LintExpansionTile> {
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<ProfileType>(
context: context,
builder: (context) {
return const _ProfileTypeDialog();
},
);
if (destinationProfileType == ProfileType.newProfile) {
showDialog<String>(
context: context,
builder: (context) {
return _NewProfileDialog(rule: rule);
},
);
} else if (destinationProfileType ==
ProfileType.existingProfile) {
showDialog<String>(
context: context,
builder: (context) {
return _ExistingProfileDialog(rule: rule);
},
);
}
},
),
),
@@ -139,3 +164,145 @@ class _LintExpansionTileState extends State<LintExpansionTile> {
);
}
}
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<FormState>();
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<ProfilesStore>(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<ProfilesStore>(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'),
),
],
);
}
}

View File

@@ -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<SavedRuleTile> {
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,
),
],
);
}
}