diff --git a/lib/components/history/date_divider.dart b/lib/components/history/date_divider.dart index 572e5ca..5794bb1 100644 --- a/lib/components/history/date_divider.dart +++ b/lib/components/history/date_divider.dart @@ -2,38 +2,13 @@ import 'package:flutter/material.dart'; import '../../bloc/theme/theme_bloc.dart'; -class DateDivider extends StatelessWidget { - final String? text; - final DateTime? date; +class TextDivider extends StatelessWidget { + final String text; - const DateDivider({ - this.text, - this.date, + const TextDivider({ Key? key, - }) : assert((text == null) ^ (date == null)), - super(key: key); - - String getHumanReadableDate(DateTime date) { - const List monthTable = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - - final int day = date.day; - final String month = monthTable[date.month - 1]; - final int year = date.year; - return '$day. $month $year'; - } + required this.text, + }) : super(key: key); @override Widget build(BuildContext context) => BlocBuilder( @@ -47,9 +22,7 @@ class DateDivider extends StatelessWidget { horizontal: 10, ), child: DefaultTextStyle.merge( - child: (text != null) - ? Text(text!) - : Text(getHumanReadableDate(date!)), + child: Text(text), style: TextStyle(color: colors.foreground), ), ); diff --git a/lib/components/history/search_item.dart b/lib/components/history/search_item.dart index 64fe01e..2862bc4 100644 --- a/lib/components/history/search_item.dart +++ b/lib/components/history/search_item.dart @@ -2,75 +2,102 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import '../../models/history/search.dart'; +import '../../routing/routes.dart'; +import '../../services/datetime.dart'; import '../../settings.dart'; +import 'kanji_box.dart'; class SearchItem extends StatelessWidget { - final DateTime time; - final Widget search; + final Search search; + // final Widget search; final int objectKey; final void Function()? onDelete; final void Function()? onFavourite; - final void Function()? onTap; const SearchItem({ - required this.time, required this.search, required this.objectKey, this.onDelete, this.onFavourite, - this.onTap, Key? key, }) : super(key: key); - String getTime() { - final hours = time.hour.toString().padLeft(2, '0'); - final mins = time.minute.toString().padLeft(2, '0'); - return '$hours:$mins'; - } + Widget get _child => (search.isKanji) + ? KanjiBox(kanji: search.kanjiQuery!.kanji) + : Text(search.wordQuery!.query); + + void Function() _onTap(context) => search.isKanji + ? () => Navigator.pushNamed( + context, + Routes.kanjiSearch, + arguments: search.kanjiQuery!.kanji, + ) + : () => Navigator.pushNamed( + context, + Routes.search, + arguments: search.wordQuery!.query, + ); + + MaterialPageRoute get timestamps => MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Last searched')), + body: ListView( + children: [ + for (final ts in search.timestamps.reversed) + ListTile(title: Text('${formatDate(ts)} ${formatTime(ts)}')) + ], + ), + ), + ); + + List _actions(context) => [ + SlidableAction( + backgroundColor: Colors.blue, + icon: Icons.access_time, + onPressed: (_) => Navigator.push(context, timestamps), + ), + SlidableAction( + backgroundColor: Colors.yellow, + icon: Icons.star, + onPressed: (_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TODO: implement favourites')), + ); + onFavourite?.call(); + }, + ), + SlidableAction( + backgroundColor: Colors.red, + icon: Icons.delete, + onPressed: (_) { + final Database db = GetIt.instance.get(); + Search.store.record(objectKey).delete(db); + onDelete?.call(); + }, + ), + ]; @override Widget build(BuildContext context) { return Slidable( endActionPane: ActionPane( motion: const ScrollMotion(), - children: [ - SlidableAction( - label: 'Favourite', - backgroundColor: Colors.yellow, - icon: Icons.star, - onPressed: (_) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TODO: implement favourites')), - ); - onFavourite?.call(); - }, - ), - SlidableAction( - label: 'Delete', - backgroundColor: Colors.red, - icon: Icons.delete, - onPressed: (_) { - final Database db = GetIt.instance.get(); - Search.store.record(objectKey).delete(db); - onDelete?.call(); - }, - ), - ], + children: _actions(context), ), child: Container( padding: const EdgeInsets.symmetric(vertical: 10), child: ListTile( - onTap: onTap, + onTap: _onTap(context), contentPadding: EdgeInsets.zero, title: Row( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text(getTime()), + child: Text(formatTime(search.timestamp)), ), DefaultTextStyle.merge( style: japaneseFont.textStyle, - child: search, + child: _child, ), ], ), diff --git a/lib/models/history/search.dart b/lib/models/history/search.dart index bb2d76d..332ebcf 100644 --- a/lib/models/history/search.dart +++ b/lib/models/history/search.dart @@ -1,3 +1,4 @@ +import 'package:get_it/get_it.dart'; import 'package:sembast/sembast.dart'; import './kanji_query.dart'; @@ -7,39 +8,81 @@ export 'package:get_it/get_it.dart'; export 'package:sembast/sembast.dart'; class Search { - final DateTime timestamp; final WordQuery? wordQuery; final KanjiQuery? kanjiQuery; + final List timestamps; Search.fromKanjiQuery({ - required this.timestamp, required KanjiQuery this.kanjiQuery, - }) : wordQuery = null; + List? timestamps, + }) : wordQuery = null, + timestamps = timestamps ?? [DateTime.now()]; Search.fromWordQuery({ - required this.timestamp, required WordQuery this.wordQuery, - }) : kanjiQuery = null; + List? timestamps, + }) : kanjiQuery = null, + timestamps = timestamps ?? [DateTime.now()]; bool get isKanji => wordQuery == null; + DateTime get timestamp => timestamps.last; + Map toJson() => { - 'timestamp': timestamp.millisecondsSinceEpoch, + 'timestamps': [for (final ts in timestamps) ts.millisecondsSinceEpoch], + 'lastTimestamp': timestamps.last.millisecondsSinceEpoch, 'wordQuery': wordQuery?.toJson(), 'kanjiQuery': kanjiQuery?.toJson(), }; - factory Search.fromJson(Map json) => - json['wordQuery'] != null - ? Search.fromWordQuery( - timestamp: - DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int), - wordQuery: WordQuery.fromJson(json['wordQuery']), - ) - : Search.fromKanjiQuery( - timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int), - kanjiQuery: KanjiQuery.fromJson(json['kanjiQuery']), - ); + factory Search.fromJson(Map json) { + final List timestamps = [ + for (final ts in json['timestamps'] as List) + DateTime.fromMillisecondsSinceEpoch(ts as int) + ]; + + return json['wordQuery'] != null + ? Search.fromWordQuery( + wordQuery: WordQuery.fromJson(json['wordQuery']), + timestamps: timestamps, + ) + : Search.fromKanjiQuery( + kanjiQuery: KanjiQuery.fromJson(json['kanjiQuery']), + timestamps: timestamps, + ); + } static StoreRef get store => intMapStoreFactory.store('search'); } + +Future addSearchToDatabase({ + required String searchTerm, + required bool isKanji, +}) async { + final DateTime now = DateTime.now(); + final db = GetIt.instance.get(); + final Filter filter = Filter.equals( + isKanji ? 'kanjiQuery.kanji' : 'wordQuery.query', + searchTerm, + ); + + final RecordSnapshot? previousSearch = + await Search.store.findFirst(db, finder: Finder(filter: filter)); + + if (previousSearch != null) { + final search = + Search.fromJson(previousSearch.value! as Map); + search.timestamps.add(now); + Search.store.record(previousSearch.key).put(db, search.toJson()); + return; + } + + Search.store.add( + db, + isKanji + ? Search.fromKanjiQuery(kanjiQuery: KanjiQuery(kanji: searchTerm)) + .toJson() + : Search.fromWordQuery(wordQuery: WordQuery(query: searchTerm)) + .toJson(), + ); +} diff --git a/lib/routing/router.dart b/lib/routing/router.dart index ce27d13..e5ada8b 100644 --- a/lib/routing/router.dart +++ b/lib/routing/router.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import '../screens/home.dart'; import '../screens/info/about.dart'; import '../screens/info/licenses.dart'; -import '../screens/search/kanji_result_page.dart'; +import '../screens/search/result_page.dart'; import '../screens/search/search_mechanisms/drawing.dart'; import '../screens/search/search_mechanisms/grade_list.dart'; import '../screens/search/search_mechanisms/radical_list.dart'; -import '../screens/search/search_results_page.dart'; import 'routes.dart'; Route generateRoute(RouteSettings settings) { @@ -20,13 +19,13 @@ Route generateRoute(RouteSettings settings) { case Routes.search: final searchTerm = args! as String; return MaterialPageRoute( - builder: (_) => SearchResultsPage(searchTerm: searchTerm), + builder: (_) => ResultPage(searchTerm: searchTerm, isKanji: false), ); case Routes.kanjiSearch: final searchTerm = args! as String; return MaterialPageRoute( - builder: (_) => KanjiResultPage(kanjiSearchTerm: searchTerm), + builder: (_) => ResultPage(searchTerm: searchTerm, isKanji: true), ); case Routes.kanjiSearchDraw: diff --git a/lib/screens/history.dart b/lib/screens/history.dart index 834c963..4afbb2c 100644 --- a/lib/screens/history.dart +++ b/lib/screens/history.dart @@ -3,18 +3,15 @@ import 'package:flutter/material.dart'; import '../components/common/loading.dart'; import '../components/common/opaque_box.dart'; import '../components/history/date_divider.dart'; -import '../components/history/kanji_box.dart'; import '../components/history/search_item.dart'; import '../models/history/search.dart'; -import '../routing/routes.dart'; +import '../services/datetime.dart'; class HistoryView extends StatelessWidget { const HistoryView({Key? key}) : super(key: key); - Database get _db => GetIt.instance.get(); - Stream> get searchStream => Search.store - .query(finder: Finder(sortOrders: [SortOrder('timestamp', false)])) + .query(finder: Finder(sortOrders: [SortOrder('lastTimestamp', false)])) .onSnapshots(_db) .map( (snapshot) => Map.fromEntries( @@ -27,6 +24,8 @@ class HistoryView extends StatelessWidget { ), ); + Database get _db => GetIt.instance.get(); + @override Widget build(BuildContext context) { return StreamBuilder>( @@ -52,72 +51,27 @@ class HistoryView extends StatelessWidget { ); } - Widget Function(BuildContext, int) historyEntryWithData( - Map data, - ) => - (context, index) { - if (index == 0) return const SizedBox.shrink(); - - final Search search = data.values.toList()[index - 1]; - final int objectKey = data.keys.toList()[index - 1]; - - late final Widget child; - late final void Function() onTap; - - if (search.isKanji) { - child = KanjiBox(kanji: search.kanjiQuery!.kanji); - onTap = () => Navigator.pushNamed( - context, - Routes.kanjiSearch, - arguments: search.kanjiQuery!.kanji, - ); - } else { - child = Text(search.wordQuery!.query); - onTap = () => Navigator.pushNamed( - context, - Routes.search, - arguments: search.wordQuery!.query, - ); - } - - return SearchItem( - time: search.timestamp, - search: child, - objectKey: objectKey, - onTap: onTap, - onDelete: () => build(context), - ); - }; - - DateTime roundToDay(DateTime date) => - DateTime(date.year, date.month, date.day); - - bool dateChangedFromLastSearch(Search prevSearch, DateTime searchDate) { - final DateTime prevSearchDate = roundToDay(prevSearch.timestamp); - return prevSearchDate != searchDate; - } - - DateTime get today => roundToDay(DateTime.now()); - DateTime get yesterday => - roundToDay(DateTime.now().subtract(const Duration(days: 1))); - Widget Function(BuildContext, int) historyEntrySeparatorWithData( List data, ) => (context, index) { final Search search = data[index]; - final DateTime searchDate = roundToDay(search.timestamp); + final DateTime searchDate = search.timestamp; - if (index == 0 || - dateChangedFromLastSearch(data[index - 1], searchDate)) { - if (searchDate == today) - return const DateDivider(text: 'Today'); - else if (searchDate == yesterday) - return const DateDivider(text: 'Yesterday'); - else - return DateDivider(date: searchDate); - } + if (index == 0 || !dateIsEqual(data[index - 1].timestamp, searchDate)) + return TextDivider(text: formatDate(roundToDay(searchDate))); return const Divider(height: 0); }; + + Widget Function(BuildContext, int) historyEntryWithData( + Map data, + ) => + (context, index) => (index == 0) + ? const SizedBox.shrink() + : SearchItem( + search: data.values.toList()[index - 1], + objectKey: data.keys.toList()[index - 1], + onDelete: () => build(context), + ); } diff --git a/lib/screens/search/kanji_result_page.dart b/lib/screens/search/kanji_result_page.dart deleted file mode 100644 index 5cee2fe..0000000 --- a/lib/screens/search/kanji_result_page.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../components/common/loading.dart'; -import '../../components/kanji/kanji_result_body.dart'; -import '../../models/history/kanji_query.dart'; -import '../../models/history/search.dart'; -import '../../services/jisho_api/kanji_search.dart'; - -class KanjiResultPage extends StatefulWidget { - final String kanjiSearchTerm; - - const KanjiResultPage({ - Key? key, - required this.kanjiSearchTerm, - }) : super(key: key); - - @override - _KanjiResultPageState createState() => _KanjiResultPageState(); -} - -class _KanjiResultPageState extends State { - bool addedToDatabase = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: FutureBuilder( - future: fetchKanji(widget.kanjiSearchTerm), - builder: (context, snapshot) { - if (!snapshot.hasData) return const LoadingScreen(); - if (snapshot.hasError) return ErrorWidget(snapshot.error!); - - if (!addedToDatabase) { - Search.store.add( - GetIt.instance.get(), - Search.fromKanjiQuery( - timestamp: DateTime.now(), - kanjiQuery: KanjiQuery(kanji: widget.kanjiSearchTerm), - ).toJson(), - ); - addedToDatabase = true; - } - - return KanjiResultBody(result: snapshot.data!); - }, - ), - ); - } -} diff --git a/lib/screens/search/result_page.dart b/lib/screens/search/result_page.dart new file mode 100644 index 0000000..f9cbc3c --- /dev/null +++ b/lib/screens/search/result_page.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../../components/common/loading.dart'; +import '../../components/kanji/kanji_result_body.dart'; +import '../../components/search/search_result_body.dart'; +import '../../models/history/search.dart'; +import '../../services/jisho_api/jisho_search.dart'; +import '../../services/jisho_api/kanji_search.dart'; + +class ResultPage extends StatefulWidget { + final String searchTerm; + final bool isKanji; + + const ResultPage({ + Key? key, + required this.searchTerm, + required this.isKanji, + }) : super(key: key); + + @override + _ResultPageState createState() => _ResultPageState(); +} + +class _ResultPageState extends State { + bool addedToDatabase = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: FutureBuilder( + future: widget.isKanji + ? fetchKanji(widget.searchTerm) + : fetchJishoResults(widget.searchTerm), + builder: (context, snapshot) { + if (!snapshot.hasData) return const LoadingScreen(); + if (snapshot.hasError) return ErrorWidget(snapshot.error!); + + if (!addedToDatabase) { + addSearchToDatabase( + searchTerm: widget.searchTerm, + isKanji: widget.isKanji, + ); + addedToDatabase = true; + } + + return widget.isKanji + ? KanjiResultBody(result: snapshot.data! as KanjiResult) + : SearchResultsBody( + results: (snapshot.data! as JishoAPIResult).data!, + ); + }, + ), + ); + } +} diff --git a/lib/screens/search/search_results_page.dart b/lib/screens/search/search_results_page.dart deleted file mode 100644 index 6c8e4c9..0000000 --- a/lib/screens/search/search_results_page.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../components/common/loading.dart'; -import '../../components/search/search_result_body.dart'; -import '../../models/history/search.dart'; -import '../../models/history/word_query.dart'; -import '../../services/jisho_api/jisho_search.dart'; - -// TODO: merge with KanjiResultPage -class SearchResultsPage extends StatefulWidget { - final String searchTerm; - - const SearchResultsPage({ - Key? key, - required this.searchTerm, - }) : super(key: key); - - @override - _SearchResultsPageState createState() => _SearchResultsPageState(); -} - -class _SearchResultsPageState extends State { - bool addedToDatabase = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: FutureBuilder( - future: fetchJishoResults(widget.searchTerm), - builder: (context, snapshot) { - if (!snapshot.hasData) return const LoadingScreen(); - if (snapshot.hasError || snapshot.data!.data == null) - return ErrorWidget(snapshot.error!); - - if (!addedToDatabase) { - Search.store.add( - GetIt.instance.get(), - Search.fromWordQuery( - timestamp: DateTime.now(), - wordQuery: WordQuery(query: widget.searchTerm), - ).toJson(), - ); - addedToDatabase = true; - } - - return SearchResultsBody( - results: snapshot.data!.data!, - ); - }, - ), - ); - } -} diff --git a/lib/services/datetime.dart b/lib/services/datetime.dart new file mode 100644 index 0000000..60546ee --- /dev/null +++ b/lib/services/datetime.dart @@ -0,0 +1,39 @@ +DateTime roundToDay(DateTime date) => DateTime(date.year, date.month, date.day); + +bool dateIsEqual(DateTime date1, DateTime date2) => + roundToDay(date1) == roundToDay(date2); + +DateTime get today => roundToDay(DateTime.now()); +DateTime get yesterday => + roundToDay(DateTime.now().subtract(const Duration(days: 1))); + +String formatTime(DateTime timestamp) { + final hours = timestamp.hour.toString().padLeft(2, '0'); + final mins = timestamp.minute.toString().padLeft(2, '0'); + return '$hours:$mins'; +} + +String formatDate(DateTime date) { + if (date == today) return 'Today'; + if (date == yesterday) return 'Yesterday'; + + const List monthTable = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + final int day = date.day; + final String month = monthTable[date.month - 1]; + final int year = date.year; + return '$day. $month $year'; +}