Compare commits

..

3 Commits

Author SHA1 Message Date
6b75eb8549 bump version. (#165) 2023-02-24 11:41:19 -08:00
36ded8a8e3 improve search experience. (#164) 2023-02-24 10:38:10 -08:00
582ac7b0be fix push notification. (#161) 2023-02-23 23:14:06 -08:00
21 changed files with 438 additions and 120 deletions

View File

@ -18,6 +18,8 @@ import flutter_local_notifications
center.delegate = self center.delegate = self
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!) WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate

View File

@ -59,7 +59,15 @@ abstract class Constants {
'(ㆆ_ㆆ)', '(ㆆ_ㆆ)',
].pickRandomly()!; ].pickRandomly()!;
static final String magicWord = <String>[
'to be over the rainbow!',
'to infinity and beyond!',
'to see the future.',
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace'; static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
} }
abstract class RegExpConstants { abstract class RegExpConstants {

View File

@ -76,12 +76,12 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> init({ Future<void> init({
bool onlyShowTargetComment = false, bool onlyShowTargetComment = false,
bool useCommentCache = false, bool useCommentCache = false,
List<Comment>? targetParents, List<Comment>? targetAncestors,
}) async { }) async {
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) { if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit( emit(
state.copyWith( state.copyWith(
comments: targetParents, comments: targetAncestors,
onlyShowTargetComment: true, onlyShowTargetComment: true,
status: CommentsStatus.allLoaded, status: CommentsStatus.allLoaded,
), ),
@ -89,8 +89,8 @@ class CommentsCubit extends Cubit<CommentsState> {
_streamSubscription = _storiesRepository _streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream( .fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids, ids: targetAncestors!.last.kids,
level: targetParents.last.level + 1, level: targetAncestors.last.level + 1,
) )
.asyncMap(_toBuildableComment) .asyncMap(_toBuildableComment)
.whereNotNull() .whereNotNull()

View File

@ -15,19 +15,19 @@ class SearchCubit extends Cubit<SearchState> {
final SearchRepository _searchRepository; final SearchRepository _searchRepository;
StreamSubscription<Story>? streamSubscription; StreamSubscription<Item>? streamSubscription;
void search(String query) { void search(String query) {
streamSubscription?.cancel(); streamSubscription?.cancel();
emit( emit(
state.copyWith( state.copyWith(
results: <Story>[], results: <Item>[],
status: SearchStatus.loading, status: SearchStatus.loading,
params: state.params.copyWith(query: query, page: 0), params: state.params.copyWith(query: query, page: 0),
), ),
); );
streamSubscription = streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched) _searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() { ..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded)); emit(state.copyWith(status: SearchStatus.loaded));
}); });
@ -43,7 +43,7 @@ class SearchCubit extends Cubit<SearchState> {
), ),
); );
streamSubscription = streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched) _searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() { ..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded)); emit(state.copyWith(status: SearchStatus.loaded));
}); });
@ -69,6 +69,8 @@ class SearchCubit extends Cubit<SearchState> {
} }
void removeFilter<T extends SearchFilter>() { void removeFilter<T extends SearchFilter>() {
if (state.params.contains<T>() == false) return;
emit( emit(
state.copyWith( state.copyWith(
params: state.params.copyWithFilterRemoved<T>(), params: state.params.copyWithFilterRemoved<T>(),
@ -78,6 +80,16 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query); search(state.params.query);
} }
void onToggled(TypeTagFilter filter) {
if (state.params.contains<TypeTagFilter>() &&
state.params.get<TypeTagFilter>() == filter) {
removeFilter<TypeTagFilter>();
} else {
removeFilter<TypeTagFilter>();
addFilter<TypeTagFilter>(filter);
}
}
void onSortToggled() { void onSortToggled() {
emit( emit(
state.copyWith( state.copyWith(
@ -90,10 +102,44 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query); search(state.params.query);
} }
void _onStoryFetched(Story story) { void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime updatedEnd = end.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime? existingStart =
state.params.get<DateTimeRangeFilter>()?.startTime;
final DateTime? existingEnd =
state.params.get<DateTimeRangeFilter>()?.endTime;
if (existingStart == updatedStart && existingEnd == updatedEnd) return;
addFilter(
DateTimeRangeFilter(
startTime: updatedStart,
endTime: updatedEnd,
),
);
}
void onPostedByChanged(String? username) {
if (username == null) {
removeFilter<PostedByFilter>();
} else {
addFilter(PostedByFilter(author: username));
}
}
void _onItemFetched(Item item) {
emit( emit(
state.copyWith( state.copyWith(
results: List<Story>.from(state.results)..add(story), results: List<Item>.from(state.results)..add(item),
), ),
); );
} }

View File

@ -16,15 +16,15 @@ class SearchState extends Equatable {
SearchState.init() SearchState.init()
: status = SearchStatus.initial, : status = SearchStatus.initial,
results = <Story>[], results = <Item>[],
params = SearchParams.init(); params = SearchParams.init();
final List<Story> results; final List<Item> results;
final SearchStatus status; final SearchStatus status;
final SearchParams params; final SearchParams params;
SearchState copyWith({ SearchState copyWith({
List<Story>? results, List<Item>? results,
SearchStatus? status, SearchStatus? status,
SearchParams? params, SearchParams? params,
}) { }) {

View File

@ -20,20 +20,20 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
final CommentCache _commentCache; final CommentCache _commentCache;
Future<void> activateTimeMachine(Comment comment) async { Future<void> activateTimeMachine(Comment comment) async {
emit(state.copyWith(parents: <Comment>[])); emit(state.copyWith(ancestors: <Comment>[]));
final List<Comment> parents = <Comment>[]; final List<Comment> ancestors = <Comment>[];
Comment? parent = _commentCache.getComment(comment.parent); Comment? parent = _commentCache.getComment(comment.parent);
parent ??= await _sembastRepository.getCachedComment(id: comment.parent); parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
while (parent != null) { while (parent != null) {
parents.insert(0, parent); ancestors.insert(0, parent);
final int parentId = parent.parent; final int parentId = parent.parent;
parent = _commentCache.getComment(parentId); parent = _commentCache.getComment(parentId);
parent ??= await _sembastRepository.getCachedComment(id: parentId); parent ??= await _sembastRepository.getCachedComment(id: parentId);
} }
emit(state.copyWith(parents: parents)); emit(state.copyWith(ancestors: ancestors));
} }
} }

View File

@ -1,18 +1,18 @@
part of 'time_machine_cubit.dart'; part of 'time_machine_cubit.dart';
class TimeMachineState extends Equatable { class TimeMachineState extends Equatable {
const TimeMachineState({required this.parents}); const TimeMachineState({required this.ancestors});
TimeMachineState.init() : parents = <Comment>[]; TimeMachineState.init() : ancestors = <Comment>[];
final List<Comment> parents; final List<Comment> ancestors;
TimeMachineState copyWith({ TimeMachineState copyWith({
List<Comment>? parents, List<Comment>? ancestors,
}) { }) {
return TimeMachineState(parents: parents ?? this.parents); return TimeMachineState(ancestors: ancestors ?? this.ancestors);
} }
@override @override
List<Object?> get props => <Object?>[parents]; List<Object?> get props => <Object?>[ancestors];
} }

View File

@ -8,8 +8,19 @@ abstract class NumericFilter extends SearchFilter {}
abstract class TagFilter extends SearchFilter {} abstract class TagFilter extends SearchFilter {}
abstract class TypeTagFilter extends TagFilter {
static List<TypeTagFilter> all = <TypeTagFilter>[
const StoryFilter(),
const PollFilter(),
const CommentFilter(),
const FrontPageFilter(),
const AskHnFilter(),
const ShowHnFilter(),
];
}
class DateTimeRangeFilter implements NumericFilter { class DateTimeRangeFilter implements NumericFilter {
DateTimeRangeFilter({ const DateTimeRangeFilter({
this.startTime, this.startTime,
this.endTime, this.endTime,
}); });
@ -37,7 +48,7 @@ class DateTimeRangeFilter implements NumericFilter {
} }
class PostedByFilter implements TagFilter { class PostedByFilter implements TagFilter {
PostedByFilter({required this.author}); const PostedByFilter({required this.author});
final String author; final String author;
@ -47,8 +58,8 @@ class PostedByFilter implements TagFilter {
} }
} }
class FrontPageFilter implements TagFilter { class FrontPageFilter implements TypeTagFilter {
FrontPageFilter(); const FrontPageFilter();
@override @override
String get query { String get query {
@ -56,8 +67,8 @@ class FrontPageFilter implements TagFilter {
} }
} }
class ShowHnFilter implements TagFilter { class ShowHnFilter implements TypeTagFilter {
ShowHnFilter(); const ShowHnFilter();
@override @override
String get query { String get query {
@ -65,8 +76,8 @@ class ShowHnFilter implements TagFilter {
} }
} }
class AskHnFilter implements TagFilter { class AskHnFilter implements TypeTagFilter {
AskHnFilter(); const AskHnFilter();
@override @override
String get query { String get query {
@ -74,8 +85,8 @@ class AskHnFilter implements TagFilter {
} }
} }
class PollFilter implements TagFilter { class PollFilter implements TypeTagFilter {
PollFilter(); const PollFilter();
@override @override
String get query { String get query {
@ -83,8 +94,8 @@ class PollFilter implements TagFilter {
} }
} }
class StoryFilter implements TagFilter { class StoryFilter implements TypeTagFilter {
StoryFilter(); const StoryFilter();
@override @override
String get query { String get query {
@ -92,8 +103,17 @@ class StoryFilter implements TagFilter {
} }
} }
class CommentFilter implements TypeTagFilter {
const CommentFilter();
@override
String get query {
return 'comment';
}
}
class CombinedFilter implements TagFilter { class CombinedFilter implements TagFilter {
CombinedFilter({required this.filters}); const CombinedFilter({required this.filters});
final List<TagFilter> filters; final List<TagFilter> filters;

View File

@ -70,7 +70,6 @@ class SearchParams extends Equatable {
filters.whereType<NumericFilter>(); filters.whereType<NumericFilter>();
final List<TagFilter> tagFilters = <TagFilter>[ final List<TagFilter> tagFilters = <TagFilter>[
...filters.whereType<TagFilter>(), ...filters.whereType<TagFilter>(),
CombinedFilter(filters: <TagFilter>[StoryFilter(), PollFilter()]),
]; ];
if (numericFilters.isNotEmpty) { if (numericFilters.isNotEmpty) {

View File

@ -13,7 +13,7 @@ class SearchRepository {
final Dio _dio; final Dio _dio;
Stream<Story> search({ Stream<Item> search({
required SearchParams params, required SearchParams params,
}) async* { }) async* {
final String url = '$_baseUrl${params.filteredQuery}'; final String url = '$_baseUrl${params.filteredQuery}';
@ -36,37 +36,53 @@ class SearchRepository {
final int score = hit['points'] as int? ?? 0; final int score = hit['points'] as int? ?? 0;
final int descendants = hit['num_comments'] as int? ?? 0; final int descendants = hit['num_comments'] as int? ?? 0;
// Getting rid of comments, only keeping stories for convenience.
// Don't judge me.
if (title.isEmpty) {
continue;
}
final String url = hit['url'] as String? ?? ''; final String url = hit['url'] as String? ?? '';
final String type = final String type =
title.toLowerCase().contains('poll:') ? 'poll' : 'story'; title.toLowerCase().contains('poll:') ? 'poll' : 'story';
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final int id = int.parse(hit['objectID'] as String? ?? '0'); final int id = int.parse(hit['objectID'] as String? ?? '0');
final Story story = Story( if (title.isEmpty) {
descendants: descendants, final String text = hit['comment_text'] as String? ?? '';
id: id, final String parsedText = await compute<String, String>(
score: score, HtmlUtil.parseHtml,
time: createdAt, text,
by: by, );
title: title, final int parentId = hit['parent_id'] as int? ?? 0;
text: parsedText, final Comment comment = Comment(
url: url, id: id,
type: type, score: score,
// response doesn't contain kids and parts. time: createdAt,
kids: const <int>[], by: by,
parts: const <int>[], text: parsedText,
); kids: const <int>[],
yield story; parent: parentId,
dead: false,
deleted: false,
level: 0,
);
yield comment;
} else {
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final Story story = Story(
descendants: descendants,
id: id,
score: score,
time: createdAt,
by: by,
title: title,
text: parsedText,
url: url,
type: type,
// response doesn't contain kids and parts.
kids: const <int>[],
parts: const <int>[],
);
yield story;
}
} }
return; return;
} }

View File

@ -74,7 +74,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order, context.read<PreferenceCubit>().state.order,
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments, targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache, useCommentCache: args.useCommentCache,
), ),
), ),
@ -116,7 +116,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order, context.read<PreferenceCubit>().state.order,
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments, targetAncestors: args.targetComments,
), ),
), ),
], ],
@ -453,7 +453,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
children: <Widget>[ children: <Widget>[
ListTile( ListTile(
leading: const Icon(Icons.av_timer), leading: const Icon(Icons.av_timer),
title: const Text('View parents'), title: const Text('View ancestors'),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
onTimeMachineActivated(comment); onTimeMachineActivated(comment);

View File

@ -90,9 +90,10 @@ class LoginDialog extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: Dimens.pt18, left: Dimens.pt18,
right: Dimens.pt6,
), ),
child: Text( child: Text(
Constants.errorMessage, Constants.loginErrorMessage,
style: const TextStyle( style: const TextStyle(
color: Palette.grey, color: Palette.grey,
fontSize: TextDimens.pt12, fontSize: TextDimens.pt12,

View File

@ -6,6 +6,7 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/models/models.dart'; import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -88,6 +89,7 @@ class MorePopupMenu extends StatelessWidget {
state.user.description, state.user.description,
), ),
onTap: () { onTap: () {
Navigator.pop(context);
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (BuildContext context) => AlertDialog( builder: (BuildContext context) => AlertDialog(
@ -121,6 +123,15 @@ class MorePopupMenu extends StatelessWidget {
}, },
), ),
actions: <Widget>[ actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text( child: const Text(
@ -213,4 +224,45 @@ class MorePopupMenu extends StatelessWidget {
), ),
); );
} }
void onSearchUserTapped(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return BlocProvider<SearchCubit>(
create: (_) => SearchCubit()
..addFilter(
PostedByFilter(
author: item.by,
),
),
child: Container(
height: MediaQuery.of(context).size.height - Dimens.pt120,
color: Theme.of(context).canvasColor,
margin: const EdgeInsets.only(top: Dimens.pt12),
child: Material(
child: Column(
children: <Widget>[
Container(
height: Dimens.pt4,
width: Dimens.pt24,
decoration: BoxDecoration(
color: Palette.grey,
borderRadius: BorderRadius.circular(Dimens.pt16),
),
),
const Expanded(
child: SearchScreen(
fromUserDialog: true,
),
)
],
),
),
),
);
},
);
}
} }

View File

@ -52,7 +52,7 @@ class TimeMachineDialog extends StatelessWidget {
const SizedBox( const SizedBox(
width: Dimens.pt8, width: Dimens.pt8,
), ),
const Text('Parents:'), const Text('Ancestors:'),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: const Icon( icon: const Icon(
@ -67,7 +67,8 @@ class TimeMachineDialog extends StatelessWidget {
Expanded( Expanded(
child: ListView( child: ListView(
children: <Widget>[ children: <Widget>[
for (final Comment c in state.parents) ...<Widget>[ for (final Comment c
in state.ancestors) ...<Widget>[
CommentTile( CommentTile(
comment: c, comment: c,
myUsername: myUsername:

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
@ -34,15 +35,6 @@ class _ProfileScreenState extends State<ProfileScreen>
PageType pageType = PageType.notification; PageType pageType = PageType.notification;
final List<String> magicWords = <String>[
'to be a lord.',
'to conquer the world.',
'to be over the rainbow!',
'to bless humanity with long-lasting peace.',
'to save the world',
'to infinity and beyond!',
];
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
@ -56,7 +48,6 @@ class _ProfileScreenState extends State<ProfileScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
final String magicWord = (magicWords..shuffle()).first;
return BlocBuilder<AuthBloc, AuthState>( return BlocBuilder<AuthBloc, AuthState>(
builder: (BuildContext context, AuthState authState) { builder: (BuildContext context, AuthState authState) {
return BlocConsumer<NotificationCubit, NotificationState>( return BlocConsumer<NotificationCubit, NotificationState>(
@ -238,7 +229,7 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
Settings( Settings(
authState: authState, authState: authState,
magicWord: magicWord, magicWord: Constants.magicWord,
pageType: pageType, pageType: pageType,
onLoginTapped: onLoginTapped, onLoginTapped: onLoginTapped,
), ),

View File

@ -12,7 +12,15 @@ import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart';
class SearchScreen extends StatefulWidget { class SearchScreen extends StatefulWidget {
const SearchScreen({super.key}); const SearchScreen({
super.key,
this.fromUserDialog = false,
});
/// If user gets to [SearchScreen] from user dialog on Tablet,
/// we navigate to [ItemScreen] directly instead of injecting the
/// item into [SplitViewCubit].
final bool fromUserDialog;
@override @override
_SearchScreenState createState() => _SearchScreenState(); _SearchScreenState createState() => _SearchScreenState();
@ -37,6 +45,7 @@ class _SearchScreenState extends State<SearchScreen> {
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Column( body: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -68,18 +77,13 @@ class _SearchScreenState extends State<SearchScreen> {
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
const SizedBox( const SizedBox(
width: 8, width: Dimens.pt8,
), ),
DateTimeRangeFilterChip( DateTimeRangeFilterChip(
filter: state.params.get<DateTimeRangeFilter>(), filter: state.params.get<DateTimeRangeFilter>(),
onDateTimeRangeUpdated: onDateTimeRangeUpdated: context
(DateTime start, DateTime end) => .read<SearchCubit>()
context.read<SearchCubit>().addFilter( .onDateTimeRangeUpdated,
DateTimeRangeFilter(
startTime: start,
endTime: end,
),
),
onDateTimeRangeRemoved: context onDateTimeRangeRemoved: context
.read<SearchCubit>() .read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>, .removeFilter<DateTimeRangeFilter>,
@ -87,6 +91,14 @@ class _SearchScreenState extends State<SearchScreen> {
const SizedBox( const SizedBox(
width: Dimens.pt8, width: Dimens.pt8,
), ),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged:
context.read<SearchCubit>().onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip( CustomChip(
onSelected: (_) => onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(), context.read<SearchCubit>().onSortToggled(),
@ -100,13 +112,9 @@ class _SearchScreenState extends State<SearchScreen> {
in CustomDateTimeRange.values) ...<Widget>[ in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip( CustomRangeFilterChip(
range: range, range: range,
onTap: (DateTime start, DateTime end) => onTap: context
context.read<SearchCubit>().addFilter( .read<SearchCubit>()
DateTimeRangeFilter( .onDateTimeRangeUpdated,
startTime: start,
endTime: end,
),
),
), ),
const SizedBox( const SizedBox(
width: Dimens.pt8, width: Dimens.pt8,
@ -115,12 +123,52 @@ class _SearchScreenState extends State<SearchScreen> {
], ],
), ),
), ),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onToggled(filter),
selected: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
),
],
],
),
),
if (state.status == SearchStatus.loading && if (state.status == SearchStatus.loading &&
state.results.isEmpty) ...<Widget>[ state.results.isEmpty) ...<Widget>[
const SizedBox( const SizedBox(
height: Dimens.pt100, height: Dimens.pt100,
), ),
const CustomCircularProgressIndicator(), const Center(
child: CustomCircularProgressIndicator(),
),
],
if (state.status == SearchStatus.loaded &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: Text(
'Nothing found...',
style: TextStyle(
color: Palette.grey,
),
),
),
], ],
Expanded( Expanded(
child: SmartRefresher( child: SmartRefresher(
@ -160,19 +208,35 @@ class _SearchScreenState extends State<SearchScreen> {
children: <Widget>[ children: <Widget>[
...state.results ...state.results
.map( .map(
(Story e) => <Widget>[ (Item e) => <Widget>[
FadeIn( if (e is Story)
child: StoryTile( FadeIn(
showWebPreview: child: StoryTile(
prefState.complexStoryTileEnabled, showWebPreview:
showMetadata: prefState.metadataEnabled, prefState.complexStoryTileEnabled,
showUrl: prefState.urlEnabled, showMetadata: prefState.metadataEnabled,
story: e, showUrl: prefState.urlEnabled,
onTap: () => goToItemScreen( story: e,
args: ItemScreenArgs(item: e), onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
forceNewScreen: widget.fromUserDialog,
),
),
)
else if (e is Comment)
FadeIn(
child: CommentTile(
myUsername: '',
actionable: false,
comment: e,
onStoryLinkTapped: (_) {},
fetchMode: FetchMode.eager,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
forceNewScreen: widget.fromUserDialog,
),
), ),
), ),
),
if (!prefState.complexStoryTileEnabled) if (!prefState.complexStoryTileEnabled)
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,

View File

@ -35,7 +35,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
}, },
selected: filter != null, selected: filter != null,
label: label:
'''from ${_formatDateTime(filter?.startTime) ?? 'START DATE'} to ${_formatDateTime(filter?.endTime) ?? 'END DATE'}''', '''from ${_formatDateTime(filter?.startTime) ?? 'X'} to ${_formatDateTime(filter?.endTime) ?? 'Y'}''',
); );
} }

View File

@ -1,21 +1,107 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/models/search_params.dart'; import 'package:hacki/models/search_params.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PostedByFilterChip extends StatelessWidget { class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({ const PostedByFilterChip({
super.key, super.key,
required this.filter, required this.filter,
required this.onChanged,
}); });
final PostedByFilter? filter; final PostedByFilter? filter;
final ValueChanged<String?> onChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomChip( return CustomChip(
onSelected: (bool value) {}, onSelected: (_) async {
final String? username = await onChipTapped(context);
if (username == filter?.author) {
return;
}
onChanged(username);
},
selected: filter != null, selected: filter != null,
label: '''posted by ${filter?.author ?? ''}''', label: '''posted by ${filter?.author ?? ''}'''.trimRight(),
); );
} }
Future<String?> onChipTapped(BuildContext context) async {
final TextEditingController usernameController = TextEditingController();
if (filter?.author != null) {
usernameController.text = filter!.author;
}
final String? username = await showDialog<String?>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return SimpleDialog(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: usernameController,
cursorColor: Palette.orange,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
Padding(
padding: const EdgeInsets.only(
right: Dimens.pt12,
),
child: ButtonBar(
children: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, filter?.author),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text(
'Clear',
),
),
ElevatedButton(
onPressed: () {
final String text = usernameController.text.trim();
Navigator.pop(context, text.isEmpty ? null : text);
},
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Palette.deepOrange),
),
child: const Text(
'Confirm',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Palette.white,
),
),
),
],
),
),
],
);
},
);
return username;
}
} }

View File

@ -25,6 +25,7 @@ class CommentTile extends StatelessWidget {
this.opUsername, this.opUsername,
this.actionable = true, this.actionable = true,
this.level = 0, this.level = 0,
this.onTap,
}); });
final String? myUsername; final String? myUsername;
@ -32,12 +33,16 @@ class CommentTile extends StatelessWidget {
final Comment comment; final Comment comment;
final int level; final int level;
final bool actionable; final bool actionable;
final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped; final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped; final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped; final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped; final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped; final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
/// Override for search screen.
final VoidCallback? onTap;
static final Map<int, Color> _colors = <int, Color>{}; static final Map<int, Color> _colors = <int, Color>{};
@ -120,6 +125,8 @@ class CommentTile extends StatelessWidget {
if (actionable) { if (actionable) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse(); context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
} }
}, },
child: Column( child: Column(
@ -190,6 +197,7 @@ class CommentTile extends StatelessWidget {
key: ValueKey<int>(comment.id), key: ValueKey<int>(comment.id),
comment: comment, comment: comment,
onLinkTapped: _onLinkTapped, onLinkTapped: _onLinkTapped,
onTap: onTap,
), ),
), ),
), ),
@ -339,11 +347,15 @@ class _CommentText extends StatelessWidget {
super.key, super.key,
required this.comment, required this.comment,
required this.onLinkTapped, required this.onLinkTapped,
this.onTap,
}); });
final Comment comment; final Comment comment;
final void Function(LinkableElement) onLinkTapped; final void Function(LinkableElement) onLinkTapped;
/// Override for search screen.
final VoidCallback? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final PreferenceState prefState = context.read<PreferenceCubit>().state; final PreferenceState prefState = context.read<PreferenceCubit>().state;
@ -363,7 +375,13 @@ class _CommentText extends StatelessWidget {
linkStyle: linkStyle, linkStyle: linkStyle,
onOpen: onLinkTapped, onOpen: onLinkTapped,
), ),
onTap: () => onTextTapped(context), onTap: () {
if (onTap == null) {
onTextTapped(context);
} else {
onTap!.call();
}
},
contextMenuBuilder: ( contextMenuBuilder: (
BuildContext context, BuildContext context,
EditableTextState editableTextState, EditableTextState editableTextState,
@ -380,7 +398,13 @@ class _CommentText extends StatelessWidget {
style: style, style: style,
linkStyle: linkStyle, linkStyle: linkStyle,
onOpen: onLinkTapped, onOpen: onLinkTapped,
onTap: () => onTextTapped(context), onTap: () {
if (onTap == null) {
onTextTapped(context);
} else {
onTap!.call();
}
},
); );
} }
} }

View File

@ -6,7 +6,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/utils/html_util.dart'; import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:path_provider_android/path_provider_android.dart'; import 'package:path_provider_android/path_provider_android.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:shared_preferences_android/shared_preferences_android.dart'; import 'package:shared_preferences_android/shared_preferences_android.dart';
@ -34,14 +35,21 @@ abstract class Fetcher {
static const int _subscriptionUpperLimit = 15; static const int _subscriptionUpperLimit = 15;
static Future<void> fetchReplies() async { static Future<void> fetchReplies() async {
final PreferenceRepository preferenceRepository = PreferenceRepository(); final Logger logger = Logger();
final PreferenceRepository preferenceRepository =
PreferenceRepository(logger: logger);
final AuthRepository authRepository = AuthRepository( final AuthRepository authRepository = AuthRepository(
preferenceRepository: preferenceRepository, preferenceRepository: preferenceRepository,
logger: logger,
); );
final StoriesRepository storiesRepository = StoriesRepository(); final StoriesRepository storiesRepository = StoriesRepository();
final SembastRepository sembastRepository = SembastRepository(); final SembastRepository sembastRepository = SembastRepository();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
final String? username = await authRepository.username; final String? username = await authRepository.username;
final List<int> unreadIds = await preferenceRepository.unreadCommentsIds; final List<int> unreadIds = await preferenceRepository.unreadCommentsIds;

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.1.1+92 version: 1.2.0+93
publish_to: none publish_to: none
environment: environment: