diff --git a/.gitignore b/.gitignore index cd340fc..e1b2613 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ .pub/ /build/ /ios/ +/coverage/ # Web related lib/generated_plugin_registrant.dart diff --git a/README.md b/README.md index d2d9bce..a7b0894 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,43 @@ # Jap Vocabs -![deploy](https://github.com/Darklod/jap-vocabs/workflows/deploy/badge.svg?branch=master) -![dev](https://github.com/Darklod/jap-vocabs/workflows/dev/badge.svg?branch=develop) -![codecov](https://codecov.io/gh/Darklod/jap-vocabs/branch/develop/graph/badge.svg?token=C4RT80DY1S) -![Flutter version](https://img.shields.io/badge/flutter-v1.22.2-blue?logo=flutter) -![style: pedantic](https://img.shields.io/badge/style-pedantic-00b4ab.svg) +[![release](https://github.com/Darklod/jap-vocabs/workflows/release/badge.svg?branch=master)](https://github.com/Darklod/jap-vocabs/actions?query=workflow%3Arelease) +[![dev](https://github.com/Darklod/jap-vocabs/workflows/dev/badge.svg?branch=develop)](https://github.com/Darklod/jap-vocabs/actions?query=workflow%3Adev) +[![codecov](https://codecov.io/gh/Darklod/jap-vocabs/branch/develop/graph/badge.svg?token=C4RT80DY1S)](https://codecov.io/gh/Darklod/jap-vocabs) +[![Flutter version](https://img.shields.io/badge/flutter-v1.22.2-blue?logo=flutter)](https://flutter.dev/docs/development/tools/sdk/releases) +[![style: pedantic](https://img.shields.io/badge/style-pedantic-00b4ab.svg)](https://pub.dev/packages/pedantic) +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FDarklod%2Fjap-vocabs.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2FDarklod%2Fjap-vocabs?ref=badge_shield) + +### To Do +- [ ] Notifications +- [x] Sorting +- [x] Filter +- [ ] Statistics Page +- [ ] Remove, Update examples +- [ ] Automatic Backups +- [ ] Language localization +- [ ] Redesign Add/Edit Page + +### Folder Structure + +``` +lib +├── components # Common widgets shared between pages +├── database # Database configuration and Dao classes +├── models # Models +├── pages # Page widgets +│ ├─ home +│ │ ├── components # Local widgets used only in the home page +│ │ └── home.dart +│ ├─ details +│ └─ ... +├── redux # Redux folders +│ ├─ actions +│ ├─ reducers +│ ├─ state +│ ├─ thunk +│ └─ store.dart +├── utils # Common functions +├── main.dart +└── routes.dart # Contains the routes and imports all pages. +``` + diff --git a/lib/pages/details/components/md2_indicator.dart b/lib/components/md2_indicator.dart similarity index 100% rename from lib/pages/details/components/md2_indicator.dart rename to lib/components/md2_indicator.dart diff --git a/lib/database/item_dao.dart b/lib/database/item_dao.dart index baf7e8e..b52781d 100644 --- a/lib/database/item_dao.dart +++ b/lib/database/item_dao.dart @@ -1,8 +1,9 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:jap_vocab/models/item.dart'; import 'package:jap_vocab/database/app_database.dart'; import 'package:jap_vocab/database/review_dao.dart'; +import 'package:jap_vocab/redux/state/filter_state.dart'; +import 'package:jap_vocab/redux/state/order_state.dart'; import 'package:sembast/sembast.dart'; class ItemDao { @@ -34,35 +35,68 @@ class ItemDao { await _store.delete(await _db, finder: finder); } - Future> getAllItems({@required String type, String search}) async { - var finder = Finder(filter: Filter.equals('type', type)); + Future> getAllItems({FilterState filter, OrderState order}) async { + var filters = [Filter.equals('type', filter.type)]; - if (search != null && search.isNotEmpty) { - finder = Finder( - filter: Filter.and( + if (filter.search != null && filter.search.isNotEmpty) { + filters.add( + Filter.or( [ - Filter.equals('type', type), - Filter.or( - [ - Filter.matches('text', search), - Filter.matches('reading', search) - ], - ), + Filter.matches('text', filter.search), + Filter.matches('reading', filter.search), ], ), ); } - final recordSnapshot = await _store.find(await _db, finder: finder); + if (filter.jlpt != null && filter.jlpt.isNotEmpty) { + filters.add(Filter.inList('jlpt', filter.jlpt)); + } + + if (filter.type == 'word' && + filter.partOfSpeech != null && + filter.partOfSpeech.isNotEmpty) { + filters.add(Filter.custom((record) { + for (var i = 0; i < filter.partOfSpeech.length; i++) { + if (record.value['part_of_speech'].contains(filter.partOfSpeech[i])) { + return true; + } + } + return false; + })); + } + + final recordSnapshot = await _store.find( + await _db, + finder: Finder(filter: Filter.and(filters)), + ); if (recordSnapshot != null) { - return Future.wait(recordSnapshot.map((snapshot) async { + final futureList = recordSnapshot.map((snapshot) async { final item = Item.fromMap(snapshot.value); item.review1 = await ReviewDao().getReviewById(item.reviewId1); item.review2 = await ReviewDao().getReviewById(item.reviewId2); return item; - }).toList()); + }).toList(); + + var list = await Future.wait(futureList); + + if (list == null || list.isEmpty) return []; + + if (filter.level != null && filter.level.isNotEmpty) { + list = list.where((Item item) { + if (item.streak >= 6.0 && filter.level.contains('strong')) { + return true; + } else if (item.streak >= 4.0 && filter.level.contains('medium')) { + return true; + } else { + return filter.level.contains('weak'); + } + }).toList(); + } + + return list..sort(Item.comparator(order.field, order.mode)); } return null; } diff --git a/lib/models/item.dart b/lib/models/item.dart index f69fafe..a9f3b1e 100644 --- a/lib/models/item.dart +++ b/lib/models/item.dart @@ -35,30 +35,19 @@ class Item { this.reviewId2, }); - int _checkJLPT(int jlpt) { - if (jlpt < 0 || jlpt > 5) { - throw RangeError.range(jlpt, 0, 5); - } - - return jlpt; - } - Map toMap() { final map = { 'id': id, 'text': text, 'type': type, - 'jlpt': jlpt, - 'favorite': favorite, + 'meaning': meaning, 'examples': examples == null ? [] : examples.map((e) => e.toMap()).toList(), 'reviewId1': reviewId1, 'reviewId2': reviewId2, }; - if (meaning != null && meaning.isNotEmpty) { - map['meaning'] = meaning; - } + // Don't persist empty fields that are not required if (reading != null && reading.isNotEmpty) { map['reading'] = reading; @@ -68,7 +57,15 @@ class Item { map['part_of_speech'] = partOfSpeech; } - if (numberOfStrokes != null) { + if (favorite) { + map['favorite'] = favorite; + } + + if (jlpt != null && jlpt > 0) { + map['jlpt'] = jlpt; + } + + if (numberOfStrokes != null && numberOfStrokes > 0) { map['number_of_strokes'] = numberOfStrokes; } @@ -80,7 +77,7 @@ class Item { id: map['id'], text: map['text'], type: map['type'], - favorite: map['favorite'], + favorite: map['favorite'] ?? false, reading: map['reading'], meaning: map['meaning'], examples: map['examples'] == null @@ -114,7 +111,7 @@ class Item { type: type ?? this.type, reading: reading ?? this.reading, meaning: meaning ?? this.meaning, - jlpt: jlpt ?? _checkJLPT(this.jlpt), + jlpt: jlpt ?? this.jlpt, numberOfStrokes: numberOfStrokes ?? this.numberOfStrokes, partOfSpeech: partOfSpeech ?? this.partOfSpeech, favorite: favorite ?? this.favorite, @@ -124,9 +121,37 @@ class Item { ); } + // Compute total accuracy + double get accuracy { + if (review1 == null && review2 == null) return 0.0; + if (review1 != null && review2 != null) { + return review1.accuracy * review2.accuracy; + } + + if (review1 != null) { + return review1.accuracy; + } else { + return review2.accuracy; + } + } + + // Compute mean streak + double get streak { + if (review1 == null && review2 == null) return 0.0; + if (review1 != null && review2 != null) { + return (review1.streak + review2.streak) * 0.5; + } + + if (review1 != null) { + return review1.streak.toDouble(); + } else { + return review2.streak.toDouble(); + } + } + DateTime get nextReview { if (review1?.next == null && review2?.next == null) return null; - + var r1 = 8640000000000000; // maxMillisecondsSinceEpoch var r2 = 8640000000000000; // maxMillisecondsSinceEpoch @@ -148,4 +173,24 @@ class Item { @override int get hashCode => id.hashCode ^ text.hashCode ^ type.hashCode; + + static Comparator comparator(String field, String mode) { + final mult = mode == 'ASC' ? -1 : 1; + + switch (field) { + case 'Alphabetical': + return (a, b) => a.text.compareTo(b.text) * mult; + case 'Streak': + return (a, b) => a.streak.compareTo(b.streak) * mult; + case 'Accuracy': + return (a, b) => a.accuracy.compareTo(b.accuracy) * mult; + case 'Next Review': + default: + return (a, b) { + final dateA = a.nextReview ?? DateTime.now(); + final dateB = b.nextReview ?? DateTime.now(); + return dateA.compareTo(dateB) * mult; + }; + } + } } diff --git a/lib/models/review.dart b/lib/models/review.dart index 6cc03cc..76197e4 100644 --- a/lib/models/review.dart +++ b/lib/models/review.dart @@ -56,8 +56,8 @@ class Review { ef: map['EF'], interval: map['interval'], streak: map['streak'], - last: map['last'] != null ? map['last'].toDateTime() : null, - next: map['next'] != null ? map['next'].toDateTime() : null, + last: map['last'] is Timestamp ? map['last'].toDateTime() : null, + next: map['next'] is Timestamp ? map['next'].toDateTime() : null, timesCorrect: map['times_correct'], timesIncorrect: map['times_incorrect'], reviewType: map['review_type'], diff --git a/lib/pages/details/components/details_appbar.dart b/lib/pages/details/components/details_appbar.dart index a137afe..93a3e96 100644 --- a/lib/pages/details/components/details_appbar.dart +++ b/lib/pages/details/components/details_appbar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:jap_vocab/models/item.dart'; import 'package:jap_vocab/pages/details/components/confirm_dialog.dart'; -import 'package:jap_vocab/pages/details/components/md2_indicator.dart'; +import 'package:jap_vocab/components/md2_indicator.dart'; import 'package:jap_vocab/redux/state/app_state.dart'; import 'package:jap_vocab/redux/thunk/items.dart'; import 'package:redux/redux.dart'; diff --git a/lib/pages/home/components/filter_sheet.dart b/lib/pages/home/components/filter_sheet.dart deleted file mode 100644 index a48d2a1..0000000 --- a/lib/pages/home/components/filter_sheet.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:chips_choice/chips_choice.dart'; -import 'package:flutter/material.dart'; - -class FilterSheet extends StatelessWidget { - FilterSheet({Key key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return FractionallySizedBox( - heightFactor: 0.8, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SortSection(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Divider(), - ), - FilterSection(), - ], - ), - ); - } -} - -class FilterSection extends StatefulWidget { - FilterSection({Key key}) : super(key: key); - - @override - _FilterSectionState createState() => _FilterSectionState(); -} - -class _FilterSectionState extends State { - var _partOfSpeech = []; - var _jlpt = []; - var _level = []; - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).textTheme.subtitle1.copyWith( - // color: Theme.of(context).accentColor, - fontWeight: FontWeight.bold, - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16.0), - child: Text('フィルター', style: style), - ), - ChipsChoice.multiple( - value: _partOfSpeech, - options: [ - ChipsChoiceOption(value: '動詞', label: '動詞'), - ChipsChoiceOption(value: '名詞', label: '名詞'), - ChipsChoiceOption(value: '形容詞', label: '形容詞'), - ChipsChoiceOption(value: '名形容動詞', label: '名形容動詞'), - ], - onChanged: (value) => setState(() => _partOfSpeech = value), - itemConfig: ChipsChoiceItemConfig( - showCheckmark: false, - unselectedBrightness: Brightness.dark, - selectedBrightness: Brightness.dark, - ), - isWrapped: true, - ), - ChipsChoice.multiple( - value: _jlpt, - options: [ - ChipsChoiceOption(value: 5, label: 'N5'), - ChipsChoiceOption(value: 4, label: 'N4'), - ChipsChoiceOption(value: 3, label: 'N3'), - ChipsChoiceOption(value: 2, label: 'N2'), - ChipsChoiceOption(value: 1, label: 'N1'), - ], - onChanged: (value) => setState(() => _jlpt = value), - itemConfig: ChipsChoiceItemConfig( - showCheckmark: false, - unselectedBrightness: Brightness.dark, - selectedBrightness: Brightness.dark, - ), - isWrapped: true, - ), - ChipsChoice.multiple( - value: _level, - options: [ - ChipsChoiceOption(value: 'weak', label: 'Weak'), - ChipsChoiceOption(value: 'medium', label: 'Medium'), - ChipsChoiceOption(value: 'strong', label: 'Strong'), - ], - onChanged: (value) => setState(() => _level = value), - itemConfig: ChipsChoiceItemConfig( - showCheckmark: false, - unselectedBrightness: Brightness.dark, - selectedBrightness: Brightness.dark, - ), - isWrapped: true, - ), - ], - ); - } -} - -class SortSection extends StatelessWidget { - SortSection({Key key}) : super(key: key); - - final sorts = [ - 'Alphabetical', - 'Next Review', - 'Last Review', - 'Streak', - 'Accuracy' - ]; - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).textTheme.subtitle1.copyWith( - // color: Theme.of(context).accentColor, - fontWeight: FontWeight.bold, - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16.0), - child: Text('並べ替え', style: style), - ), - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - physics: NeverScrollableScrollPhysics(), - itemCount: sorts.length, - itemBuilder: (context, index) { - return ListTile( - title: Text( - sorts[index], - style: - TextStyle(color: index == 2 ? Colors.orangeAccent : null), - ), - selected: index == 2, - // selectedTileColor: Colors.red, - trailing: index == 2 - ? Icon(Icons.arrow_upward, color: Colors.orangeAccent) - : null, - onTap: () {}, - ); - }, - ), - ], - ); - } -} diff --git a/lib/pages/home/components/filter_sheet/components/filter_section.dart b/lib/pages/home/components/filter_sheet/components/filter_section.dart new file mode 100644 index 0000000..2300762 --- /dev/null +++ b/lib/pages/home/components/filter_sheet/components/filter_section.dart @@ -0,0 +1,136 @@ +import 'package:chips_choice/chips_choice.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:jap_vocab/redux/state/app_state.dart'; +import 'package:jap_vocab/redux/thunk/filter.dart'; +import 'package:redux/redux.dart'; + +class FilterSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final _chipConfig = ChipsChoiceItemConfig( + showCheckmark: false, + unselectedBrightness: Brightness.dark, + selectedBrightness: Brightness.dark, + selectedColor: Theme.of(context).accentColor, + margin: EdgeInsets.zero, + spacing: 8.0, + labelStyle: Theme.of(context).textTheme.caption, + ); + + final _style = Theme.of(context).textTheme.subtitle1; + + // TODO: add select all / deselect all button + return StoreConnector( + converter: (Store store) => _ViewModel.create(store), + builder: (context, _ViewModel vm) { + return Container( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.min, + children: [ + Text('JLPT', style: _style), + ChipsChoice.multiple( + value: vm.jlpt, + options: [ + ChipsChoiceOption(value: 5, label: 'N5'), + ChipsChoiceOption(value: 4, label: 'N4'), + ChipsChoiceOption(value: 3, label: 'N3'), + ChipsChoiceOption(value: 2, label: 'N2'), + ChipsChoiceOption(value: 1, label: 'N1'), + ], + onChanged: (value) => vm.setJLPT(value), + itemConfig: _chipConfig, + padding: EdgeInsets.zero, + isWrapped: true, + ), + Text('Level', style: _style), + ChipsChoice.multiple( + value: vm.level, + options: [ + ChipsChoiceOption(value: 'weak', label: 'Weak'), + ChipsChoiceOption(value: 'medium', label: 'Medium'), + ChipsChoiceOption(value: 'strong', label: 'Strong'), + ], + onChanged: (value) => vm.setLevel(value), + itemConfig: _chipConfig, + padding: EdgeInsets.zero, + isWrapped: true, + ), + Text('Part Of Speech', style: _style), + ChipsChoice.multiple( + value: vm.partOfSpeech, + options: [ + ChipsChoiceOption(value: 'avverbio', label: 'avverbio'), + ChipsChoiceOption( + value: 'aggettivo-no', label: 'aggettivo-no'), + ChipsChoiceOption( + value: 'aggettivo-na', label: 'aggettivo-na'), + ChipsChoiceOption(value: 'verbo', label: 'verbo'), + ChipsChoiceOption( + value: 'verbo-transitivo', label: 'verbo transitivo'), + ChipsChoiceOption( + value: 'verbo-intransitivo', label: 'verbo intransitivo'), + ChipsChoiceOption(value: 'verbo-suru', label: 'verbo suru'), + ChipsChoiceOption(value: 'sostantivo', label: 'sostantivo'), + ], + onChanged: (value) => vm.setPartOfSpeech(value), + itemConfig: _chipConfig, + padding: EdgeInsets.zero, + isWrapped: true, + ), + ], + ), + ); + }, + ); + } +} + +class _ViewModel { + final List jlpt; + final List level; + final List partOfSpeech; + + final Function(List) setJLPT; + final Function(List) setLevel; + final Function(List) setPartOfSpeech; + + _ViewModel({ + this.jlpt, + this.level, + this.partOfSpeech, + this.setJLPT, + this.setLevel, + this.setPartOfSpeech, + }); + + factory _ViewModel.create(Store store) { + final _jlpt = store.state.filterState.jlpt; + final _level = store.state.filterState.level; + final _partOfSpeech = store.state.filterState.partOfSpeech; + + void _setJLPT(List jlpt) { + store.dispatch(changeJLPT(jlpt)); + } + + void _setLevel(List level) { + store.dispatch(changeLevel(level)); + } + + void _setPartOfSpeech(List partOfSpeech) { + store.dispatch(changePartOfSpeech(partOfSpeech)); + } + + return _ViewModel( + jlpt: _jlpt, + level: _level, + partOfSpeech: _partOfSpeech, + setJLPT: _setJLPT, + setLevel: _setLevel, + setPartOfSpeech: _setPartOfSpeech, + ); + } +} diff --git a/lib/pages/home/components/filter_sheet/components/sort_section.dart b/lib/pages/home/components/filter_sheet/components/sort_section.dart new file mode 100644 index 0000000..08222ff --- /dev/null +++ b/lib/pages/home/components/filter_sheet/components/sort_section.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:jap_vocab/redux/state/app_state.dart'; +import 'package:jap_vocab/redux/thunk/order.dart'; +import 'package:redux/redux.dart'; + +class SortSection extends StatelessWidget { + SortSection({Key key}) : super(key: key); + + final sorts = [ + 'Alphabetical', + 'Next Review', + 'Streak', + 'Accuracy', + ]; + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store state) => _ViewModel.create(state), + builder: (context, _ViewModel vm) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + /*Container( + padding: const EdgeInsets.all(16.0), + child: Text('並べ替え', style: style), + ),*/ + ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: NeverScrollableScrollPhysics(), + itemCount: sorts.length, + itemBuilder: (context, index) { + return ListTile( + title: Text(sorts[index], + style: sorts[index] == vm.field + ? TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orangeAccent, + ) + : null), + selected: sorts[index] == vm.field, + trailing: sorts[index] == vm.field + ? Icon( + vm.mode == 'ASC' + ? Icons.arrow_upward + : Icons.arrow_downward, + color: Colors.orangeAccent, + ) + : null, + onTap: () { + if (vm.field != sorts[index]) { + vm.setField(sorts[index]); + } else { + vm.setMode(vm.mode == 'ASC' ? 'DESC' : 'ASC'); + } + }, + ); + }, + ), + ], + ); + }, + ); + } +} + +class _ViewModel { + final String field; + final String mode; + final Function(String) setField; + final Function(String) setMode; + + _ViewModel({this.field, this.mode, this.setField, this.setMode}); + + factory _ViewModel.create(Store store) { + final _field = store.state.orderState.field; + final _mode = store.state.orderState.mode; + + void _setField(String field) { + store.dispatch(changeSortField(field)); + } + + void _setMode(String mode) { + store.dispatch(changeSortMode(mode)); + } + + return _ViewModel( + field: _field, + mode: _mode, + setField: _setField, + setMode: _setMode, + ); + } +} diff --git a/lib/pages/home/components/filter_sheet/filter_sheet.dart b/lib/pages/home/components/filter_sheet/filter_sheet.dart new file mode 100644 index 0000000..0a0fa57 --- /dev/null +++ b/lib/pages/home/components/filter_sheet/filter_sheet.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:jap_vocab/components/md2_indicator.dart'; +import 'package:jap_vocab/pages/home/components/filter_sheet/components/filter_section.dart'; +import 'package:jap_vocab/pages/home/components/filter_sheet/components/sort_section.dart'; + +class FilterSheet extends StatelessWidget { + FilterSheet({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 0, + length: 2, + child: Container( + height: MediaQuery.of(context).size.height * 0.54, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + padding: const EdgeInsets.only(top: 4), + child: TabBar( + tabs: [ + Tab(text: '並べ替え'), + Tab(text: 'フィルター'), + ], + unselectedLabelColor: Colors.white30, + labelColor: Colors.white, + indicatorSize: TabBarIndicatorSize.label, + indicator: MD2Indicator( + indicatorHeight: 4.0, + indicatorColor: Colors.white, + horizontalPadding: 16.0, + ), + ), + ), + Expanded( + child: Container( + color: Theme.of(context).primaryColorLight, + child: TabBarView( + children: [ + SortSection(), + FilterSection(), + ], + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/components/home_appbar.dart b/lib/pages/home/components/home_appbar.dart index c48f019..e7bb0cb 100644 --- a/lib/pages/home/components/home_appbar.dart +++ b/lib/pages/home/components/home_appbar.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:jap_vocab/pages/home/components/filter_sheet.dart'; +import 'package:jap_vocab/pages/home/components/filter_sheet/filter_sheet.dart'; import 'package:jap_vocab/pages/home/components/reivew_type_switch.dart'; import 'package:jap_vocab/pages/home/components/seachbar.dart'; diff --git a/lib/pages/home/components/list_item.dart b/lib/pages/home/components/list_item.dart index e97182e..2eaabf6 100644 --- a/lib/pages/home/components/list_item.dart +++ b/lib/pages/home/components/list_item.dart @@ -14,19 +14,6 @@ class ListItem extends StatelessWidget { const ListItem({this.item, this.onTap}); - double get _percent { - if (item.review1 == null && item.review2 == null) return 0.0; - if (item.review1 != null && item.review2 != null) { - return item.review1.accuracy * item.review2.accuracy; - } - - if (item.review1 != null) { - return item.review1.accuracy; - } else { - return item.review2.accuracy; - } - } - String get _date => Date.format(item.nextReview) ?? 'New'; String get _example { @@ -87,10 +74,10 @@ class ListItem extends StatelessWidget { CircularPercentIndicator( radius: 40.0, lineWidth: 4.0, - percent: _percent, + percent: item.accuracy, circularStrokeCap: CircularStrokeCap.round, - center: Text('${(_percent * 100).toInt()}'), - progressColor: colorPercent(_percent), + center: Text('${(item.accuracy * 100).toInt()}'), + progressColor: colorPercent(item.accuracy), ), ], ), diff --git a/lib/pages/home/components/seachbar.dart b/lib/pages/home/components/seachbar.dart index 82ee424..f514bc6 100644 --- a/lib/pages/home/components/seachbar.dart +++ b/lib/pages/home/components/seachbar.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; -import 'package:jap_vocab/redux/actions/filter_actions.dart'; import 'package:jap_vocab/redux/state/app_state.dart'; -import 'package:jap_vocab/redux/thunk/items.dart'; +import 'package:jap_vocab/redux/thunk/filter.dart'; import 'package:redux/redux.dart'; class SearchBar extends StatefulWidget with PreferredSizeWidget { @@ -90,13 +89,12 @@ class _ViewModel { _ViewModel({this.setSearch, this.resetSearch}); factory _ViewModel.create(Store store) { - void _setSearch(String value) async { - await store.dispatch(ChangeSearchAction(value)); - await store.dispatch(getItems()); + void _setSearch(String value) { + store.dispatch(changeSearch(value)); } - void _resetSearch() async { - await _setSearch(''); + void _resetSearch() { + _setSearch(''); } return _ViewModel( diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 63adbfb..24ac9b7 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -16,11 +16,8 @@ class HomePage extends StatelessWidget { appBar: HomeAppBar(), body: StoreConnector( converter: (Store store) => _ViewModel.create(store), - onInit: (Store store) => - store.dispatch(getItems()), // TODO: CHECK + onInit: (Store store) => store.dispatch(getItems()), builder: (context, _ViewModel vm) { - print('HomePage build) ${vm.items.length}'); - return ListView.builder( padding: const EdgeInsets.only(bottom: 80), itemCount: vm.items?.length ?? 0, @@ -55,20 +52,13 @@ class _ViewModel { factory _ViewModel.create(Store store) { final _items = store.state.itemsState.items; - final _sorted = List.from(_items) - ..sort((a, b) { - final dateA = a.nextReview ?? DateTime.now(); - final dateB = b.nextReview ?? DateTime.now(); - - return dateA.compareTo(dateB); - }); void _getItems() { store.dispatch(getItems()); } return _ViewModel( - items: _sorted, + items: _items, getAllItems: _getItems, ); } diff --git a/lib/pages/reviews/reviews.dart b/lib/pages/reviews/reviews.dart index d0c0ce4..35d5d77 100644 --- a/lib/pages/reviews/reviews.dart +++ b/lib/pages/reviews/reviews.dart @@ -54,8 +54,6 @@ class _ReviewPageState extends State { bool get _enable => _quality != -1; - // TODO: tasto ? per info sulla qualità - void _showHelp() { showDialog( context: context, diff --git a/lib/redux/actions/filter_actions.dart b/lib/redux/actions/filter_actions.dart index b103e81..1b74b12 100644 --- a/lib/redux/actions/filter_actions.dart +++ b/lib/redux/actions/filter_actions.dart @@ -7,3 +7,18 @@ class ChangeTypeAction { final String type; const ChangeTypeAction(this.type); } + +class ChangeJLTPAction { + final List jlpt; + const ChangeJLTPAction(this.jlpt); +} + +class ChangeLevelAction { + final List level; + const ChangeLevelAction(this.level); +} + +class ChangePartOfSpeechAction { + final List partOfSpeech; + const ChangePartOfSpeechAction(this.partOfSpeech); +} diff --git a/lib/redux/actions/order_actions.dart b/lib/redux/actions/order_actions.dart new file mode 100644 index 0000000..44d15fa --- /dev/null +++ b/lib/redux/actions/order_actions.dart @@ -0,0 +1,9 @@ +class ChangeSortFieldAction { + final String field; + const ChangeSortFieldAction(this.field); +} + +class ChangeSortModeAction { + final String mode; + const ChangeSortModeAction(this.mode); +} diff --git a/lib/redux/reducers/app_reducers.dart b/lib/redux/reducers/app_reducers.dart index a94b15f..40ba000 100644 --- a/lib/redux/reducers/app_reducers.dart +++ b/lib/redux/reducers/app_reducers.dart @@ -1,5 +1,6 @@ import 'package:jap_vocab/redux/reducers/filter_reducers.dart'; import 'package:jap_vocab/redux/reducers/item_reducers.dart'; +import 'package:jap_vocab/redux/reducers/order_reducers.dart'; import 'package:jap_vocab/redux/reducers/reviews_reducers.dart'; import 'package:jap_vocab/redux/reducers/settings_reducers.dart'; import 'package:jap_vocab/redux/state/app_state.dart'; @@ -7,6 +8,7 @@ import 'package:jap_vocab/redux/state/app_state.dart'; AppState appStateReducer(AppState state, action) { return AppState( filterState: filterReducer(state.filterState, action), + orderState: orderReducer(state.orderState, action), itemsState: itemsReducer(state.itemsState, action), reviewsState: reviewsReducer(state.reviewsState, action), settingsState: settingsReducer(state.settingsState, action), diff --git a/lib/redux/reducers/filter_reducers.dart b/lib/redux/reducers/filter_reducers.dart index 9faf94c..2220eac 100644 --- a/lib/redux/reducers/filter_reducers.dart +++ b/lib/redux/reducers/filter_reducers.dart @@ -5,6 +5,9 @@ import 'package:redux/redux.dart'; Reducer filterReducer = combineReducers([ TypedReducer(_changeSearch), TypedReducer(_changeType), + TypedReducer(_changeJLPT), + TypedReducer(_changeLevel), + TypedReducer(_changePartOfSpeech), ]); FilterState _changeSearch(FilterState state, ChangeSearchAction action) { @@ -14,3 +17,18 @@ FilterState _changeSearch(FilterState state, ChangeSearchAction action) { FilterState _changeType(FilterState state, ChangeTypeAction action) { return state.copyWith(type: action.type); } + +FilterState _changeJLPT(FilterState state, ChangeJLTPAction action) { + return state.copyWith(jlpt: action.jlpt); +} + +FilterState _changeLevel(FilterState state, ChangeLevelAction action) { + return state.copyWith(level: action.level); +} + +FilterState _changePartOfSpeech( + FilterState state, + ChangePartOfSpeechAction action, +) { + return state.copyWith(partOfSpeech: action.partOfSpeech); +} diff --git a/lib/redux/reducers/order_reducers.dart b/lib/redux/reducers/order_reducers.dart new file mode 100644 index 0000000..1f7da14 --- /dev/null +++ b/lib/redux/reducers/order_reducers.dart @@ -0,0 +1,16 @@ +import 'package:jap_vocab/redux/actions/order_actions.dart'; +import 'package:jap_vocab/redux/state/order_state.dart'; +import 'package:redux/redux.dart'; + +Reducer orderReducer = combineReducers([ + TypedReducer(_changeField), + TypedReducer(_changeMode), +]); + +OrderState _changeField(OrderState state, ChangeSortFieldAction action) { + return state.copyWith(field: action.field); +} + +OrderState _changeMode(OrderState state, ChangeSortModeAction action) { + return state.copyWith(mode: action.mode); +} diff --git a/lib/redux/state/app_state.dart b/lib/redux/state/app_state.dart index d17954e..1ad10d7 100644 --- a/lib/redux/state/app_state.dart +++ b/lib/redux/state/app_state.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:jap_vocab/redux/state/filter_state.dart'; import 'package:jap_vocab/redux/state/items_state.dart'; +import 'package:jap_vocab/redux/state/order_state.dart'; import 'package:jap_vocab/redux/state/reviews_state.dart'; import 'package:jap_vocab/redux/state/settings_state.dart'; @@ -9,30 +10,35 @@ class AppState { final ItemsState itemsState; final ReviewsState reviewsState; final FilterState filterState; + final OrderState orderState; final SettingsState settingsState; const AppState({ @required this.itemsState, @required this.reviewsState, @required this.filterState, + @required this.orderState, @required this.settingsState, }); - AppState.initialState({SettingsState settings}) + AppState.initialState({SettingsState settings, OrderState order}) : itemsState = ItemsState.initial(), reviewsState = ReviewsState.initial(), filterState = FilterState.initial(), + orderState = order ?? OrderState.initial(), settingsState = settings ?? SettingsState.initial(); static AppState fromJson(dynamic json) { return AppState.initialState( settings: SettingsState.fromJson(json['settingsState']), + order: OrderState.fromJson(json['orderState']), ); } dynamic toJson() { return { 'settingsState': settingsState.toJson(), + 'orderState': orderState.toJson(), }; } @@ -40,12 +46,14 @@ class AppState { ItemsState itemsState, ReviewsState reviewsState, FilterState filterState, + OrderState orderState, SettingsState settingsState, }) { return AppState( itemsState: itemsState ?? this.itemsState, reviewsState: reviewsState ?? this.reviewsState, filterState: filterState ?? this.filterState, + orderState: orderState ?? this.orderState, settingsState: settingsState ?? this.settingsState, ); } diff --git a/lib/redux/state/filter_state.dart b/lib/redux/state/filter_state.dart index e025f75..fdbc79b 100644 --- a/lib/redux/state/filter_state.dart +++ b/lib/redux/state/filter_state.dart @@ -4,24 +4,57 @@ import 'package:flutter/foundation.dart'; class FilterState { final String type; final String search; - const FilterState({@required this.type, this.search}); + + final List jlpt; + final List level; + final List partOfSpeech; + + const FilterState({ + @required this.type, + this.search, + this.jlpt, + this.level, + this.partOfSpeech, + }); FilterState.initial() : type = 'word', - search = null; + search = null, + jlpt = null, + level = null, + partOfSpeech = null; - FilterState copyWith({type, search}) { + FilterState copyWith({ + String type, + String search, + List jlpt, + List level, + List partOfSpeech, + }) { return FilterState( type: type ?? this.type, search: search ?? this.search, + jlpt: jlpt ?? this.jlpt, + level: level ?? this.level, + partOfSpeech: partOfSpeech ?? this.partOfSpeech, ); } @override bool operator ==(Object other) => identical(this, other) || - other is FilterState && type == other.type && search == other.search; + other is FilterState && + type == other.type && + search == other.search && + jlpt == other.jlpt && + level == other.level && + partOfSpeech == other.partOfSpeech; @override - int get hashCode => type.hashCode ^ search.hashCode; + int get hashCode => + type.hashCode ^ + search.hashCode ^ + jlpt.hashCode ^ + level.hashCode ^ + partOfSpeech.hashCode; } diff --git a/lib/redux/state/order_state.dart b/lib/redux/state/order_state.dart new file mode 100644 index 0000000..6269a09 --- /dev/null +++ b/lib/redux/state/order_state.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class OrderState { + final String field; + final String mode; + const OrderState({this.field, this.mode}); + + OrderState.initial() + : field = 'Next Review', + mode = 'DESC'; + + OrderState copyWith({field, mode}) { + return OrderState( + field: field ?? this.field, + mode: mode ?? this.mode, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OrderState && field == other.field && mode == other.mode; + + @override + int get hashCode => field.hashCode ^ mode.hashCode; + + static OrderState fromJson(dynamic json) { + if (json == null) return null; + + return OrderState( + field: json['field'] ?? 'Next Review', + mode: json['mode'] ?? 'DESC', + ); + } + + dynamic toJson() { + return { + 'field': field, + 'mode': mode, + }; + } +} diff --git a/lib/redux/thunk/filter.dart b/lib/redux/thunk/filter.dart index 38f418a..a32f199 100644 --- a/lib/redux/thunk/filter.dart +++ b/lib/redux/thunk/filter.dart @@ -5,6 +5,13 @@ import 'package:jap_vocab/redux/thunk/reviews.dart'; import 'package:redux/redux.dart'; import 'package:redux_thunk/redux_thunk.dart'; +ThunkAction changeSearch(String search) { + return (Store store) async { + await store.dispatch(ChangeSearchAction(search)); + await store.dispatch(getItems()); + }; +} + ThunkAction changeType(String type) { return (Store store) async { await store.dispatch(ChangeTypeAction(type)); @@ -12,3 +19,24 @@ ThunkAction changeType(String type) { await store.dispatch(getReviews()); }; } + +ThunkAction changeJLPT(List jlpt) { + return (Store store) async { + await store.dispatch(ChangeJLTPAction(jlpt)); + await store.dispatch(getItems()); + }; +} + +ThunkAction changeLevel(List level) { + return (Store store) async { + await store.dispatch(ChangeLevelAction(level)); + await store.dispatch(getItems()); + }; +} + +ThunkAction changePartOfSpeech(List partOfSpeech) { + return (Store store) async { + await store.dispatch(ChangePartOfSpeechAction(partOfSpeech)); + await store.dispatch(getItems()); + }; +} diff --git a/lib/redux/thunk/items.dart b/lib/redux/thunk/items.dart index 89b9867..1efdd98 100644 --- a/lib/redux/thunk/items.dart +++ b/lib/redux/thunk/items.dart @@ -14,10 +14,10 @@ ThunkAction getItems() { return (Store store) async { await store.dispatch(LoadingItemsAction()); - var type = store.state.filterState.type; - var search = store.state.filterState.search; + var filter = store.state.filterState; + var order = store.state.orderState; - final items = await ItemDao().getAllItems(type: type, search: search); + final items = await ItemDao().getAllItems(filter: filter, order: order); await store.dispatch(LoadedItemsAction(items)); }; } diff --git a/lib/redux/thunk/order.dart b/lib/redux/thunk/order.dart new file mode 100644 index 0000000..bab9e57 --- /dev/null +++ b/lib/redux/thunk/order.dart @@ -0,0 +1,23 @@ +import 'package:jap_vocab/redux/actions/order_actions.dart'; +import 'package:jap_vocab/redux/state/app_state.dart'; +import 'package:jap_vocab/redux/thunk/items.dart'; +import 'package:redux/redux.dart'; +import 'package:redux_thunk/redux_thunk.dart'; + +ThunkAction changeSortField(String field) { + return (Store store) async { + if (field != null && field.isNotEmpty) { + await store.dispatch(ChangeSortFieldAction(field)); + await store.dispatch(getItems()); + } + }; +} + +ThunkAction changeSortMode(String mode) { + return (Store store) async { + if (mode != null && mode.isNotEmpty) { + await store.dispatch(ChangeSortModeAction(mode)); + await store.dispatch(getItems()); + } + }; +} diff --git a/lib/utils/date.dart b/lib/utils/date.dart index 7fb150a..2a22fa9 100644 --- a/lib/utils/date.dart +++ b/lib/utils/date.dart @@ -14,7 +14,7 @@ class Date { final millis = date.millisecondsSinceEpoch - DateTime.now().millisecondsSinceEpoch; - + if (millis <= 0) return '今'; final seconds = millis / 1000; diff --git a/test/date_test.dart b/test/date_test.dart index 42101dc..1b1531b 100644 --- a/test/date_test.dart +++ b/test/date_test.dart @@ -5,6 +5,22 @@ void main() { group('format', () { final now = DateTime.now(); + test('date and time', () { + final date = DateTime(now.year, now.month, now.day, 14, 30); + + expect( + Date.format(date, full: true), + '${date.year}年${date.month}月${date.day}日', + reason: 'It must be ${date.year}/${date.month}/${date.day}', + ); + + expect( + Date.format(date, full: true, time: true), + '${date.year}年${date.month}月${date.day}日 14時30分', + reason: 'It must be ${date.year}/${date.month}/${date.day} 14:30', + ); + }); + test('today', () { final date = DateTime(now.year, now.month, now.day, 7); diff --git a/test/item_test.dart b/test/item_test.dart index 5e7b891..f8d3b61 100644 --- a/test/item_test.dart +++ b/test/item_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:jap_vocab/models/example.dart'; import 'package:jap_vocab/models/item.dart'; import 'package:jap_vocab/models/review.dart'; import 'package:uuid/uuid.dart'; @@ -135,4 +136,97 @@ void main() { expect(item.nextReview == r2.next, true); }); }); + + group('toMap', () { + final item = Item( + text: '溶岩', + meaning: 'lava', + type: 'word', + reading: 'ようがん', + jlpt: 3, + partOfSpeech: 'sostantivo', + ); + + test('example', () { + final item1 = item.toMap(); + final item2 = item.copyWith(examples: []).toMap(); + final item3 = item.copyWith(examples: [Example(), Example()]).toMap(); + + expect(item1['examples'], []); + expect(item2['examples'], isInstanceOf>>()); + expect(item3['examples'], isInstanceOf>>()); + }); + + test('empty fields', () { + final item1 = Item( + text: '水', + meaning: 'acqua', + type: 'word', + ).toMap(); + + final item2 = Item( + text: '水', + meaning: 'acqua', + type: 'word', + reading: 'mizu', + favorite: true, + partOfSpeech: 'sostantivo', + jlpt: 3, + numberOfStrokes: 12, + ).toMap(); + + final fieldsToCheck = [ + 'reading', + 'part_of_speech', + 'favorite', + 'jlpt', + 'number_of_strokes', + ]; + + for (var i = 0; i < fieldsToCheck.length; i++) { + expect( + item1.containsKey(fieldsToCheck[i]), + false, + reason: 'Field \'${fieldsToCheck[i]}\' must be absent', + ); + expect( + item2.containsKey(fieldsToCheck[i]), + true, + reason: 'Field \'${fieldsToCheck[i]}\' must be present', + ); + } + }); + }); + + group('fromMap', () { + test('examples argument', () { + final item1 = Item.fromMap({ + 'examples': null, + }); + final item2 = Item.fromMap({ + 'examples': >[], + }); + final item3 = Item.fromMap({ + 'examples': >[{}], + }); + + expect( + item1.examples, + [], + reason: 'It must be an empty list', + ); + + expect( + item2.examples, + isInstanceOf>(), + reason: 'It must be an instance of List', + ); + + expect( + item3.examples, + isInstanceOf>(), + reason: 'It must be an instance of List', + ); + }); + }); } diff --git a/test/review_test.dart b/test/review_test.dart index c6f6b0e..e27f5a5 100644 --- a/test/review_test.dart +++ b/test/review_test.dart @@ -1,9 +1,63 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jap_vocab/models/review.dart'; +import 'package:sembast/timestamp.dart'; void main() { + group('accuracy', () { + test('It must be 0.5', () { + final r = Review(timesCorrect: 5, timesIncorrect: 5); + expect(r.accuracy, 0.5); + }); + + test('It must be 0', () { + final r1 = Review(timesCorrect: 0, timesIncorrect: 5); + final r2 = Review(timesCorrect: 0, timesIncorrect: 0); + expect(r1.accuracy, 0.0); + expect(r2.accuracy, 0.0); + }); + + test('It must be 1', () { + final r = Review(timesCorrect: 5, timesIncorrect: 0); + expect(r.accuracy, 1.0); + }); + }); + + test('toMap', () { + final r = Review(last: DateTime.now(), next: null); + + expect( + r.toMap()['last'], + isInstanceOf(), + reason: 'It must be a Timestamp', + ); + expect(r.toMap()['next'], null); + }); + + group('fromMap', () { + test('Timestamp argument', () { + final map1 = {'last': null, 'next': Timestamp.now()}; + final r1 = Review.fromMap(map1); + + expect( + r1.next, + isInstanceOf(), + reason: 'It must be a DateTime', + ); + expect(r1.last, null); + }); + + test('Other types argument', () { + final map2 = {'last': 'test', 'next': DateTime.now()}; + final r2 = Review.fromMap(map2); + + expect(r2.last, null); + expect(r2.next, null); + }); + }); + group('copyWith', () { final review = Review( + id: '2345u334udfsnfb347', ef: 2.6, interval: 6, reviewType: 'meaning', @@ -35,24 +89,66 @@ void main() { itemId: 'itemId', ); - expect(review.id == copy.id, false, reason: 'id must be different'); - expect(review.ef == copy.ef, false, reason: 'ef must be different'); - expect(review.interval == copy.interval, false, - reason: 'interval must be different'); - expect(review.last == copy.last, false, reason: 'last must be different'); - expect(review.next == copy.next, false, reason: 'next must be different'); - expect(review.reviewType == copy.reviewType, false, - reason: 'reviewType must be different'); - expect(review.type == copy.type, false, reason: 'type must be different'); - expect(review.streak == copy.streak, false, - reason: 'streak must be different'); - expect(review.type == copy.type, false, reason: 'type must be different'); - expect(review.timesCorrect == copy.timesCorrect, false, - reason: 'timesCorrect must be different'); - expect(review.timesIncorrect == copy.timesIncorrect, false, - reason: 'timesIncorrect must be different'); - expect(review.itemId == copy.itemId, false, - reason: 'itemId must be different'); + expect( + review.id == copy.id, + false, + reason: 'id must be different', + ); + expect( + review.ef == copy.ef, + false, + reason: 'ef must be different', + ); + expect( + review.interval == copy.interval, + false, + reason: 'interval must be different', + ); + expect( + review.last == copy.last, + false, + reason: 'last must be different', + ); + expect( + review.next == copy.next, + false, + reason: 'next must be different', + ); + expect( + review.reviewType == copy.reviewType, + false, + reason: 'reviewType must be different', + ); + expect( + review.type == copy.type, + false, + reason: 'type must be different', + ); + expect( + review.streak == copy.streak, + false, + reason: 'streak must be different', + ); + expect( + review.type == copy.type, + false, + reason: 'type must be different', + ); + expect( + review.timesCorrect == copy.timesCorrect, + false, + reason: 'timesCorrect must be different', + ); + expect( + review.timesIncorrect == copy.timesIncorrect, + false, + reason: 'timesIncorrect must be different', + ); + expect( + review.itemId == copy.itemId, + false, + reason: 'itemId must be different', + ); expect(copy == review, false, reason: 'The copy must be not equal'); }); diff --git a/test/sm2_test.dart b/test/sm2_test.dart index c1a568a..59dd89b 100644 --- a/test/sm2_test.dart +++ b/test/sm2_test.dart @@ -46,14 +46,17 @@ void main() { var oldR = Review(); for (var i = 0; i < 100; i++) { final q = Random().nextInt(6); - var newR = SM2.newIteration(oldR, q); + final newR = SM2.newIteration(oldR, q); + + final newEF = newR.ef.toStringAsPrecision(2); + final oldEF = oldR.ef.toStringAsPrecision(2); if (q == 4) { - expect(newR.ef.toStringAsPrecision(2), oldR.ef.toStringAsPrecision(2)); + expect(newEF, oldEF); } if (q < 3) { - expect(newR.ef.toStringAsPrecision(2), oldR.ef.toStringAsPrecision(2)); + expect(newEF, oldEF); expect(newR.streak, 0); }