Compare commits

...

24 Commits

Author SHA1 Message Date
ff7e115418 fix manual pagination button. (#310) 2023-11-06 22:46:44 -08:00
0310507c96 revert html util change. (#309) 2023-11-06 19:40:53 -08:00
58c646e232 update html_util.dart (#308) 2023-11-06 17:10:10 -08:00
08328e2ca1 update url_linkifier.dart (#307) 2023-11-06 14:19:25 -08:00
86b7228ffd improve response indicator. (#306) 2023-11-06 12:45:46 -08:00
e103c88ca6 fix favorites export. (#305) 2023-11-05 22:47:45 -08:00
94323a04e0 fix response indicator. (#304) 2023-11-05 21:22:02 -08:00
4776c375a1 UX improvements on HN and in-thread search. (#303) 2023-11-05 19:48:01 -08:00
1f4e6cf41c fix pagination button. (#298) 2023-11-02 21:50:09 -07:00
be6ed35888 update version. (#297) 2023-11-02 21:09:55 -07:00
b2ea50cea6 add pagination. (#296) 2023-11-02 20:22:51 -07:00
109b9287cf fix offline webview. (#295) 2023-11-02 17:17:46 -07:00
939d55ef0d fix in-thread search. (#294) 2023-11-02 14:51:46 -07:00
3ee60e1a44 improve in-thread search UX. (#293) 2023-11-02 14:34:24 -07:00
6fe567fa02 update design of about dialog. (#292) 2023-11-02 13:42:33 -07:00
bc2d4f32c9 show index on comment tile. (#291) 2023-11-02 13:11:10 -07:00
91290e9743 update README.md (#290) 2023-11-02 12:28:09 -07:00
934f184b6f fix material 3 colors. (#289) 2023-11-02 12:04:43 -07:00
dbd48eae99 fix reply box. (#288) 2023-11-01 23:00:00 -07:00
279007191b update feature description. (#287) 2023-11-01 22:17:57 -07:00
b3fdc20fc5 add ability to use material 3. (#286) 2023-11-01 19:48:09 -07:00
3fbf5d4eea improve shortcut button. (#284) 2023-10-22 20:34:09 -07:00
332ffbb773 bump version. (#282) 2023-10-22 00:14:12 -07:00
346a6c709e fix inconsistent font size. (#281) 2023-10-21 23:50:06 -07:00
43 changed files with 1351 additions and 656 deletions

View File

@ -1,7 +1,7 @@
# <img width="64" src="https://user-images.githubusercontent.com/7277662/167775086-0b234f28-dee4-44f6-aae4-14a28ed4bbb6.png"> Hacki for Hacker News
A [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
A [Hacker News](https://news.ycombinator.com/) client built with Flutter.
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
[![Fdroid version](https://img.shields.io/f-droid/v/com.jiaqifeng.hacki)](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)

View File

@ -0,0 +1,4 @@
- Ability to use Material 3.
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,5 @@
- Ability to use pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -0,0 +1,5 @@
- Ability to use manual pagination on home screen.
- Ability to use Material 3 (experimental).
- Ability to search in thread.
- Ability to customize text scale factor.
- Ability to customize app's accent color.

View File

@ -20,6 +20,8 @@ abstract class Constants {
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
static const String hackerNewsItemLinkPrefix =
'https://news.ycombinator.com/item?id=';
static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images';

View File

@ -9,6 +9,7 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
@ -61,6 +62,10 @@ class CommentsCubit extends Cubit<CommentsState> {
final SembastRepository _sembastRepository;
final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
StreamSubscription<Comment>? _streamSubscription;
@ -108,6 +113,8 @@ class CommentsCubit extends Cubit<CommentsState> {
state.copyWith(
status: CommentsStatus.inProgress,
comments: <Comment>[],
matchedComments: <int>[],
inThreadSearchQuery: '',
currentPage: 0,
),
);
@ -213,6 +220,7 @@ class CommentsCubit extends Cubit<CommentsState> {
state.copyWith(
onlyShowTargetComment: false,
item: story,
matchedComments: <int>[],
),
);
init();
@ -349,17 +357,26 @@ class CommentsCubit extends Cubit<CommentsState> {
init(useCommentCache: true);
}
void scrollTo({
required int index,
double alignment = 0.0,
}) {
debugPrint('Scrolling to: $index, alignment: $alignment');
itemScrollController.scrollTo(
index: index,
alignment: alignment,
duration: Durations.ms400,
);
}
/// Scroll to next root level comment.
void scrollToNextRoot(
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
void scrollToNextRoot({VoidCallback? onError}) {
final int totalComments = state.comments.length;
final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value
// The header is also a part of the list view,
// thus ignoring it here.
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge < 0.7)
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge > 0.1)
.sorted((ItemPosition a, ItemPosition b) => a.index.compareTo(b.index))
.map(
(ItemPosition e) => e.index <= state.comments.length
@ -369,9 +386,29 @@ class CommentsCubit extends Cubit<CommentsState> {
.whereNotNull()
.toList();
/// The index of last comment visible on screen.
final int lastVisibleIndex = state.comments.indexOf(onScreenComments.last);
final int startIndex = min(lastVisibleIndex + 1, totalComments);
if (onScreenComments.isEmpty && state.comments.isNotEmpty) {
itemScrollController.scrollTo(
index: 1,
alignment: 0.15,
duration: Durations.ms400,
);
return;
}
final Comment? firstVisibleRootComment =
onScreenComments.firstWhereOrNull((Comment e) => e.isRoot);
late int startIndex;
if (firstVisibleRootComment != null) {
/// The index of first root level comment visible on screen.
final int firstVisibleRootCommentIndex =
state.comments.indexOf(firstVisibleRootComment);
startIndex = min(firstVisibleRootCommentIndex + 1, totalComments);
} else {
final int lastVisibleCommentIndex =
state.comments.indexOf(onScreenComments.last);
startIndex = min(lastVisibleCommentIndex + 1, totalComments);
}
for (int i = startIndex; i < totalComments; i++) {
final Comment cmt = state.comments.elementAt(i);
@ -385,13 +422,14 @@ class CommentsCubit extends Cubit<CommentsState> {
return;
}
}
if (state.status == CommentsStatus.allLoaded) {
onError?.call();
}
}
/// Scroll to previous root level comment.
void scrollToPreviousRoot(
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
void scrollToPreviousRoot() {
final List<Comment> onScreenComments = itemPositionsListener
.itemPositions.value
// The header is also a part of the list view,
@ -426,6 +464,50 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
void search(String query, {String author = ''}) {
resetSearch();
late final bool Function(Comment cmt) conditionSatisfied;
final String lowercaseQuery = query.toLowerCase();
if (query.isEmpty && author.isEmpty) {
return;
} else if (author.isEmpty) {
conditionSatisfied =
(Comment cmt) => cmt.text.toLowerCase().contains(lowercaseQuery);
} else if (query.isEmpty) {
conditionSatisfied = (Comment cmt) => cmt.by == author;
} else {
conditionSatisfied = (Comment cmt) =>
cmt.text.toLowerCase().contains(lowercaseQuery) && cmt.by == author;
}
emit(
state.copyWith(
inThreadSearchQuery: query,
inThreadSearchAuthor: author,
),
);
for (final int i in 0.to(state.comments.length, inclusive: false)) {
final Comment cmt = state.comments.elementAt(i);
if (conditionSatisfied(cmt)) {
emit(
state.copyWith(
matchedComments: <int>[...state.matchedComments, i],
),
);
}
}
}
void resetSearch() => emit(
state.copyWith(
matchedComments: <int>[],
inThreadSearchQuery: '',
inThreadSearchAuthor: '',
),
);
List<int> _sortKids(List<int> kids) {
switch (state.order) {
case CommentsOrder.natural:
@ -453,6 +535,7 @@ class CommentsCubit extends Cubit<CommentsState> {
_commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),
);
@ -461,7 +544,16 @@ class CommentsCubit extends Cubit<CommentsState> {
comment.copyWith(hidden: hidden),
];
emit(state.copyWith(comments: updatedComments));
final Map<int, Comment> updatedIdToCommentMap =
Map<int, Comment>.from(state.idToCommentMap);
updatedIdToCommentMap[comment.id] = comment;
emit(
state.copyWith(
comments: updatedComments,
idToCommentMap: updatedIdToCommentMap,
),
);
}
}

View File

@ -12,6 +12,8 @@ class CommentsState extends Equatable {
const CommentsState({
required this.item,
required this.comments,
required this.matchedComments,
required this.idToCommentMap,
required this.status,
required this.fetchParentStatus,
required this.fetchRootStatus,
@ -20,6 +22,8 @@ class CommentsState extends Equatable {
required this.onlyShowTargetComment,
required this.isOfflineReading,
required this.currentPage,
required this.inThreadSearchQuery,
required this.inThreadSearchAuthor,
});
CommentsState.init({
@ -28,14 +32,19 @@ class CommentsState extends Equatable {
required this.fetchMode,
required this.order,
}) : comments = <Comment>[],
matchedComments = <int>[],
idToCommentMap = <int, Comment>{},
status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle,
onlyShowTargetComment = false,
currentPage = 0;
currentPage = 0,
inThreadSearchQuery = '',
inThreadSearchAuthor = '';
final Item item;
final List<Comment> comments;
final Map<int, Comment> idToCommentMap;
final CommentsStatus status;
final CommentsStatus fetchParentStatus;
final CommentsStatus fetchRootStatus;
@ -44,10 +53,17 @@ class CommentsState extends Equatable {
final bool onlyShowTargetComment;
final bool isOfflineReading;
final int currentPage;
final String inThreadSearchQuery;
final String inThreadSearchAuthor;
/// Indexes of comments that matches the query for in-thread search.
final List<int> matchedComments;
CommentsState copyWith({
Item? item,
List<Comment>? comments,
List<int>? matchedComments,
Map<int, Comment>? idToCommentMap,
CommentsStatus? status,
CommentsStatus? fetchParentStatus,
CommentsStatus? fetchRootStatus,
@ -56,10 +72,13 @@ class CommentsState extends Equatable {
bool? onlyShowTargetComment,
bool? isOfflineReading,
int? currentPage,
String? inThreadSearchQuery,
String? inThreadSearchAuthor,
}) {
return CommentsState(
item: item ?? this.item,
comments: comments ?? this.comments,
matchedComments: matchedComments ?? this.matchedComments,
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
status: status ?? this.status,
@ -69,11 +88,41 @@ class CommentsState extends Equatable {
onlyShowTargetComment ?? this.onlyShowTargetComment,
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage,
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
inThreadSearchAuthor: inThreadSearchAuthor ?? this.inThreadSearchAuthor,
idToCommentMap: idToCommentMap ?? this.idToCommentMap,
);
}
Set<int> get commentIds => comments.map((Comment e) => e.id).toSet();
static final Map<int, bool> _isResponseCache = <int, bool>{};
bool isResponse(Comment comment) {
if (_isResponseCache.containsKey(comment.id)) {
return _isResponseCache[comment.id]!;
}
if (comment.isRoot) {
_isResponseCache[comment.id] = false;
return false;
}
final Comment? precedingComment = idToCommentMap[comment.parent];
if (precedingComment == null) {
_isResponseCache[comment.id] = false;
return false;
} else if (item.id == precedingComment.parent && item.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else if (idToCommentMap[precedingComment.parent]?.by == comment.by) {
_isResponseCache[comment.id] = true;
return true;
} else {
_isResponseCache[comment.id] = false;
return false;
}
}
@override
List<Object?> get props => <Object?>[
item,
@ -86,5 +135,9 @@ class CommentsState extends Equatable {
isOfflineReading,
currentPage,
comments,
matchedComments,
inThreadSearchQuery,
inThreadSearchAuthor,
idToCommentMap,
];
}

View File

@ -70,6 +70,14 @@ class PreferenceState extends Equatable {
bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get material3Enabled => _isOn<Material3Preference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void onExactMatchToggled() {
emit(
state.copyWith(
params: state.params.copyWith(
exactMatch: !state.params.exactMatch,
),
),
);
search(state.params.query);
}
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,

View File

@ -85,4 +85,8 @@ extension ContextExtension on BuildContext {
int get storyTileMaxLines {
return _storyTileMaxLines;
}
double get topPadding {
return MediaQuery.of(this).padding.top + kToolbarHeight;
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
@ -103,31 +104,26 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
context: context,
builder: (BuildContext context) {
return SafeArea(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
onTap: () => context.pop(item.url),
title: const Text('Link to article'),
),
ListTile(
onTap: () => context.pop(
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
onTap: () => context.pop(item.url),
title: const Text('Link to article'),
),
),
ListTile(
onTap: () => context.pop(
'${Constants.hackerNewsItemLinkPrefix}${item.id}',
),
title: const Text('Link to HN'),
),
],
),
);
},
);
} else {
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
linkToShare = '${Constants.hackerNewsItemLinkPrefix}${item.id}';
}
if (linkToShare != null) {

View File

@ -19,6 +19,7 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/haptic_feedback_util.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
@ -229,14 +230,23 @@ class HackiApp extends StatelessWidget {
)..init(),
),
],
child: BlocBuilder<PreferenceCubit, PreferenceState>(
child: BlocConsumer<PreferenceCubit, PreferenceState>(
listenWhen: (PreferenceState previous, PreferenceState current) =>
previous.hapticFeedbackEnabled != current.hapticFeedbackEnabled,
listener: (_, PreferenceState state) {
HapticFeedbackUtil.enabled = state.hapticFeedbackEnabled;
},
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.appColor != current.appColor ||
previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor,
previous.textScaleFactor != current.textScaleFactor ||
previous.material3Enabled != current.material3Enabled ||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>('${state.appColor}${state.font}'),
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.material3Enabled}${state.trueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
colorScheme: ColorScheme.fromSwatch(
@ -251,7 +261,7 @@ class HackiApp extends StatelessWidget {
primarySwatch: state.appColor,
brightness: Brightness.dark,
),
canvasColor: Palette.black,
canvasColor: state.trueDarkModeEnabled ? Palette.black : null,
fontFamily: state.font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,
@ -287,7 +297,61 @@ class HackiApp extends StatelessWidget {
title: 'Hacki',
debugShowCheckedModeBanner: false,
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
useMaterial3: false,
useMaterial3: state.material3Enabled,
dividerTheme: state.material3Enabled
? DividerThemeData(
color: Palette.grey.withOpacity(0.2),
)
: null,
switchTheme: state.material3Enabled
? SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states
.contains(MaterialState.selected)) {
return null;
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
)
: null,
bottomSheetTheme: state.material3Enabled
? const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
)
: null,
inputDecorationTheme: state.material3Enabled
? InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
)
: null,
sliderTheme: state.material3Enabled
? SliderThemeData(
inactiveTrackColor:
state.appColor.shade200.withOpacity(0.5),
)
: null,
outlinedButtonTheme: state.material3Enabled
? OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),
),
),
)
: null,
),
routerConfig: router,
),

View File

@ -25,13 +25,18 @@ enum DiscoverableFeature {
featureId: 'jump_up_button_with_long_press',
title: 'Shortcut',
description:
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
'''Tapping on this button will take you to the previous root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
),
jumpDownButton(
featureId: 'jump_down_button_with_long_press',
title: 'Shortcut',
description:
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''',
'''Tapping on this button will take you to the next root level comment.\n\nLong press on it to jump to the end of this thread.''',
),
searchInThread(
featureId: 'search_in_thread',
title: 'Search in Thread',
description: '''Search for comments in this thread.''',
);
const DiscoverableFeature({

View File

@ -37,12 +37,16 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const MarkReadStoriesModePreference(),
// Divider.
const NotificationModePreference(),
const SwipeGesturePreference(),
const AutoScrollModePreference(),
const CollapseModePreference(),
const ReaderModePreference(),
const CustomTabPreference(),
const ManualPaginationPreference(),
const SwipeGesturePreference(),
const HapticFeedbackPreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
const Material3Preference(),
],
);
@ -66,6 +70,8 @@ const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _hapticFeedbackModeDefaultValue = true;
const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
@ -73,6 +79,8 @@ const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false;
const bool _material3ModeDefaultValue = false;
const bool _paginationModeDefaultValue = false;
const double _textScaleFactorDefaultValue = 1;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
@ -97,7 +105,7 @@ class SwipeGesturePreference extends BooleanPreference {
String get key => 'swipeGestureMode';
@override
String get title => 'Enable Swipe Gesture';
String get title => 'Swipe Gesture';
@override
String get subtitle =>
@ -285,6 +293,45 @@ class EyeCandyModePreference extends BooleanPreference {
String get subtitle => 'some sort of magic.';
}
class ManualPaginationPreference extends BooleanPreference {
const ManualPaginationPreference({bool? val})
: super(val: val ?? _paginationModeDefaultValue);
@override
ManualPaginationPreference copyWith({required bool? val}) {
return ManualPaginationPreference(val: val);
}
@override
String get key => 'paginationMode';
@override
String get title => 'Manual Pagination';
@override
String get subtitle => '''so you can get stuff done.''';
}
class Material3Preference extends BooleanPreference {
const Material3Preference({bool? val})
: super(val: val ?? _material3ModeDefaultValue);
@override
Material3Preference copyWith({required bool? val}) {
return Material3Preference(val: val);
}
@override
String get key => 'material3Mode';
@override
String get title => 'Material 3';
@override
String get subtitle =>
'''experimental feature. Please open an issue on GitHub if you notice anything weird.''';
}
/// Whether or not to use Custom Tabs for launching URLs.
/// If false, default browser will be used.
///
@ -312,6 +359,47 @@ class CustomTabPreference extends BooleanPreference {
bool get isDisplayable => Platform.isAndroid;
}
class TrueDarkModePreference extends BooleanPreference {
const TrueDarkModePreference({bool? val})
: super(val: val ?? _trueDarkModeDefaultValue);
@override
TrueDarkModePreference copyWith({required bool? val}) {
return TrueDarkModePreference(val: val);
}
@override
String get key => 'trueDarkMode';
@override
String get title => 'True Dark Mode';
@override
String get subtitle => 'real dark.';
}
class HapticFeedbackPreference extends BooleanPreference {
const HapticFeedbackPreference({bool? val})
: super(val: val ?? _hapticFeedbackModeDefaultValue);
@override
HapticFeedbackPreference copyWith({required bool? val}) {
return HapticFeedbackPreference(val: val);
}
@override
String get key => 'hapticFeedbackMode';
@override
String get title => 'Haptic Feedback';
@override
String get subtitle => '';
@override
bool get isDisplayable => Platform.isIOS;
}
class FetchModePreference extends IntPreference {
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
@ -401,7 +489,7 @@ class StoryMarkingModePreference extends IntPreference {
String get key => 'storyMarkingMode';
@override
String get title => 'Mark a Story as Read on';
String get title => 'Mark as Read on';
}
class AppColorPreference extends IntPreference {

View File

@ -8,31 +8,36 @@ class SearchParams extends Equatable {
required this.filters,
required this.query,
required this.page,
this.sorted = false,
required this.sorted,
required this.exactMatch,
});
SearchParams.init()
: filters = <SearchFilter>{},
query = '',
page = 0,
sorted = false;
sorted = false,
exactMatch = false;
final Set<SearchFilter> filters;
final String query;
final int page;
final bool sorted;
final bool exactMatch;
SearchParams copyWith({
Set<SearchFilter>? filters,
String? query,
int? page,
bool? sorted,
bool? exactMatch,
}) {
return SearchParams(
filters: filters ?? this.filters,
query: query ?? this.query,
page: page ?? this.page,
sorted: sorted ?? this.sorted,
exactMatch: exactMatch ?? this.exactMatch,
);
}
@ -43,6 +48,7 @@ class SearchParams extends Equatable {
query: query,
page: page,
sorted: sorted,
exactMatch: exactMatch,
);
}
@ -54,16 +60,19 @@ class SearchParams extends Equatable {
query: query,
page: page,
sorted: sorted,
exactMatch: exactMatch,
);
}
String get filteredQuery {
final StringBuffer buffer = StringBuffer();
final String encodedQuery =
Uri.encodeComponent(exactMatch ? '"$query"' : query);
if (sorted) {
buffer.write('search_by_date?query=${Uri.encodeComponent(query)}');
buffer.write('search_by_date?query=$encodedQuery');
} else {
buffer.write('search?query=${Uri.encodeComponent(query)}');
buffer.write('search?query=$encodedQuery');
}
final Iterable<NumericFilter> numericFilters =
@ -111,5 +120,6 @@ class SearchParams extends Equatable {
query,
page,
sorted,
exactMatch,
];
}

View File

@ -49,7 +49,7 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
locator.get<Logger>().i('Resetting comments in CommentCache');
Future<void>.delayed(
Durations.ms500,
locator.get<CommentCache>().resetComments,

View File

@ -142,9 +142,6 @@ class _ItemScreenState extends State<ItemScreen>
final TextEditingController commentEditingController =
TextEditingController();
final FocusNode focusNode = FocusNode();
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create();
final Throttle storyLinkTapThrottle = Throttle(
@ -182,6 +179,7 @@ class _ItemScreenState extends State<ItemScreen>
FeatureDiscovery.discoverFeatures(
context,
<String>{
DiscoverableFeature.searchInThread.featureId,
DiscoverableFeature.pinToTop.featureId,
DiscoverableFeature.addStoryToFavList.featureId,
DiscoverableFeature.openStoryInWebView.featureId,
@ -218,8 +216,6 @@ class _ItemScreenState extends State<ItemScreen>
@override
Widget build(BuildContext context) {
final double topPadding =
MediaQuery.of(context).padding.top + kToolbarHeight;
return BlocBuilder<AuthBloc, AuthState>(
builder: (BuildContext context, AuthState authState) {
return MultiBlocListener(
@ -272,12 +268,10 @@ class _ItemScreenState extends State<ItemScreen>
children: <Widget>[
Positioned.fill(
child: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController,
authState: authState,
topPadding: topPadding,
topPadding: context.topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
@ -313,24 +307,24 @@ class _ItemScreenState extends State<ItemScreen>
);
},
),
Positioned(
const Positioned(
right: Dimens.pt12,
bottom: Dimens.pt36,
child: CustomFloatingActionButton(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
),
child: CustomFloatingActionButton(),
),
Positioned(
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onChanged: context.read<EditCubit>().onTextChanged,
child: Material(
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onChanged:
context.read<EditCubit>().onTextChanged,
),
),
),
],
@ -348,20 +342,15 @@ class _ItemScreenState extends State<ItemScreen>
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
body: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController,
authState: authState,
topPadding: topPadding,
topPadding: context.topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
),
floatingActionButton: CustomFloatingActionButton(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
),
floatingActionButton: const CustomFloatingActionButton(),
bottomSheet: ReplyBox(
textEditingController: commentEditingController,
focusNode: focusNode,
@ -437,42 +426,36 @@ class _ItemScreenState extends State<ItemScreen>
context: context,
builder: (BuildContext context) {
return SafeArea(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
leading: const Icon(Icons.av_timer),
title: const Text('View ancestors'),
onTap: () {
context.pop();
onTimeMachineActivated(comment);
},
enabled:
comment.level > 0 && !(comment.dead || comment.deleted),
),
ListTile(
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
locator.get<AppReviewService>().requestReview();
context.pop();
goToItemScreen(
args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true,
);
},
enabled: !(comment.dead || comment.deleted),
),
],
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
leading: const Icon(Icons.av_timer),
title: const Text('View ancestors'),
onTap: () {
context.pop();
onTimeMachineActivated(comment);
},
enabled:
comment.level > 0 && !(comment.dead || comment.deleted),
),
),
ListTile(
leading: const Icon(Icons.list),
title: const Text('View in separate thread'),
onTap: () {
locator.get<AppReviewService>().requestReview();
context.pop();
goToItemScreen(
args: ItemScreenArgs(
item: comment,
useCommentCache: true,
),
forceNewScreen: true,
);
},
enabled: !(comment.dead || comment.deleted),
),
],
),
);
},

View File

@ -34,6 +34,7 @@ class CustomAppBar extends AppBar {
),
const Spacer(),
],
const InThreadSearchIconButton(),
IconButton(
key: fontSizeIconButtonKey,
icon: Text(

View File

@ -3,108 +3,93 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CustomFloatingActionButton extends StatelessWidget {
const CustomFloatingActionButton({
required this.itemScrollController,
required this.itemPositionsListener,
super.key,
});
final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener;
@override
Widget build(BuildContext context) {
return BlocBuilder<CommentsCubit, CommentsState>(
builder: (BuildContext context, CommentsState state) {
return BlocBuilder<EditCubit, EditState>(
buildWhen: (EditState previous, EditState current) =>
previous.showReplyBox != current.showReplyBox,
builder: (BuildContext context, EditState editState) {
return AnimatedPadding(
padding: editState.showReplyBox
? const EdgeInsets.only(
bottom: Dimens.replyBoxCollapsedHeight,
)
: EdgeInsets.zero,
duration: Durations.ms200,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CustomDescribedFeatureOverlay(
feature: DiscoverableFeature.jumpUpButton,
contentLocation: ContentLocation.above,
tapTarget: const Icon(
return BlocBuilder<EditCubit, EditState>(
buildWhen: (EditState previous, EditState current) =>
previous.showReplyBox != current.showReplyBox,
builder: (BuildContext context, EditState editState) {
return AnimatedPadding(
padding: editState.showReplyBox
? const EdgeInsets.only(
bottom: Dimens.replyBoxCollapsedHeight,
)
: EdgeInsets.zero,
duration: Durations.ms200,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CustomDescribedFeatureOverlay(
feature: DiscoverableFeature.jumpUpButton,
contentLocation: ContentLocation.above,
tapTarget: const Icon(
Icons.keyboard_arrow_up,
color: Palette.white,
),
child: InkWell(
onLongPress: () =>
context.read<CommentsCubit>().scrollTo(index: 0),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Randomly generated string as heroTag to prevent
/// default [FloatingActionButton] animation.
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToPreviousRoot();
},
child: Icon(
Icons.keyboard_arrow_up,
color: Palette.white,
),
child: InkWell(
onLongPress: () => itemScrollController.scrollTo(
index: 0,
duration: Durations.ms400,
),
child: FloatingActionButton.small(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
/// Randomly generated string as heroTag to prevent
/// default [FloatingActionButton] animation.
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToPreviousRoot(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_up,
color: Theme.of(context).colorScheme.primary,
),
),
color: Theme.of(context).colorScheme.primary,
),
),
CustomDescribedFeatureOverlay(
feature: DiscoverableFeature.jumpDownButton,
tapTarget: const Icon(
Icons.keyboard_arrow_down,
color: Palette.white,
),
child: InkWell(
onLongPress: () => itemScrollController.scrollTo(
index: state.comments.length,
duration: Durations.ms400,
),
child: FloatingActionButton.small(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
/// Same as above.
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToNextRoot(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
],
),
),
);
},
CustomDescribedFeatureOverlay(
feature: DiscoverableFeature.jumpDownButton,
tapTarget: const Icon(
Icons.keyboard_arrow_down,
color: Palette.white,
),
child: InkWell(
onLongPress: () {
final CommentsCubit cubit = context.read<CommentsCubit>();
cubit.scrollTo(index: cubit.state.comments.length);
},
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Same as above.
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToNextRoot(
onError: () => context.showSnackBar(
content: '''No more root level comment below.''',
),
);
},
child: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
],
),
);
},
);

View File

@ -0,0 +1,211 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class InThreadSearchIconButton extends StatelessWidget {
const InThreadSearchIconButton({super.key});
@override
Widget build(BuildContext context) {
return OpenContainer(
closedColor: Palette.transparent,
openColor: Theme.of(context).canvasColor,
closedShape: const CircleBorder(),
closedElevation: 0,
openElevation: 0,
transitionType: ContainerTransitionType.fadeThrough,
closedBuilder: (BuildContext context, void Function() action) {
return CustomDescribedFeatureOverlay(
tapTarget: const Icon(
Icons.search,
color: Palette.white,
),
feature: DiscoverableFeature.searchInThread,
child: IconButton(
tooltip: 'Search in thread',
icon: const Icon(Icons.search),
onPressed: action,
),
);
},
openBuilder: (_, void Function({Object? returnValue}) action) =>
_InThreadSearchView(
commentsCubit: context.read<CommentsCubit>(),
action: action,
),
);
}
}
class _InThreadSearchView extends StatefulWidget {
const _InThreadSearchView({
required this.commentsCubit,
required this.action,
});
final CommentsCubit commentsCubit;
final void Function({Object? returnValue}) action;
@override
State<_InThreadSearchView> createState() => _InThreadSearchViewState();
}
class _InThreadSearchViewState extends State<_InThreadSearchView> {
final ScrollController scrollController = ScrollController();
final FocusNode focusNode = FocusNode();
final TextEditingController textEditingController = TextEditingController();
@override
void initState() {
super.initState();
scrollController.addListener(onScroll);
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
if (textEditingController.text.isEmpty) {
focusNode.requestFocus();
}
}
@override
void dispose() {
scrollController
..removeListener(onScroll)
..dispose();
focusNode.dispose();
textEditingController.dispose();
super.dispose();
}
void onScroll() => focusNode.unfocus();
@override
Widget build(BuildContext context) {
return BlocProvider<CommentsCubit>.value(
value: widget.commentsCubit,
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState previous, CommentsState current) =>
previous.matchedComments != current.matchedComments ||
previous.inThreadSearchAuthor != current.inThreadSearchAuthor,
builder: (BuildContext context, CommentsState state) {
final AuthState authState = context.read<AuthBloc>().state;
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor: Theme.of(context).canvasColor,
elevation: 0,
leadingWidth: 0,
leading: const SizedBox.shrink(),
title: Padding(
padding: const EdgeInsets.only(bottom: Dimens.pt8),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Expanded(
child: TextField(
controller: textEditingController,
focusNode: focusNode,
cursorColor: Theme.of(context).primaryColor,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search in this thread',
suffixText: '${state.matchedComments.length} results',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
),
),
),
onChanged: (String text) => widget.commentsCubit.search(
text,
author: state.inThreadSearchAuthor,
),
),
),
IconButton(
icon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: widget.action,
),
],
),
),
),
body: ListView(
controller: scrollController,
shrinkWrap: true,
children: <Widget>[
Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt12,
),
CustomChip(
selected: state.inThreadSearchAuthor == state.item.by,
label: 'by OP',
onSelected: (bool value) {
if (value) {
widget.commentsCubit.search(
state.inThreadSearchQuery,
author: state.item.by,
);
} else {
widget.commentsCubit.search(
state.inThreadSearchQuery,
);
}
},
),
const SizedBox(
width: Dimens.pt12,
),
if (authState.isLoggedIn)
CustomChip(
selected:
state.inThreadSearchAuthor == authState.username,
label: 'by me',
onSelected: (bool value) {
if (value) {
widget.commentsCubit.search(
state.inThreadSearchQuery,
author: authState.username,
);
} else {
widget.commentsCubit.search(
state.inThreadSearchQuery,
);
}
},
),
],
),
for (final int i in state.matchedComments)
CommentTile(
index: i,
comment: state.comments.elementAt(i),
fetchMode: FetchMode.lazy,
actionable: false,
collapsable: false,
onTap: () {
widget.action();
widget.commentsCubit.scrollTo(
index: i + 1,
alignment: 0.2,
);
},
),
],
),
);
},
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -27,7 +28,7 @@ class LinkIconButton extends StatelessWidget {
),
),
onPressed: () => LinkUtil.launch(
'https://news.ycombinator.com/item?id=$storyId',
'${Constants.hackerNewsItemLinkPrefix}$storyId',
context,
useHackiForHnLink: false,
),

View File

@ -18,8 +18,6 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MainView extends StatelessWidget {
const MainView({
required this.itemScrollController,
required this.itemPositionsListener,
required this.scrollOffsetListener,
required this.commentEditingController,
required this.authState,
@ -30,8 +28,6 @@ class MainView extends StatelessWidget {
super.key,
});
final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener;
final ScrollOffsetListener scrollOffsetListener;
final TextEditingController commentEditingController;
final AuthState authState;
@ -49,6 +45,9 @@ class MainView extends StatelessWidget {
children: <Widget>[
Positioned.fill(
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState previous, CommentsState current) =>
previous.comments.length != current.comments.length ||
previous.status != current.status,
builder: (BuildContext context, CommentsState state) {
return RefreshIndicator(
displacement: 100,
@ -67,8 +66,10 @@ class MainView extends StatelessWidget {
},
child: ScrollablePositionedList.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemScrollController:
context.read<CommentsCubit>().itemScrollController,
itemPositionsListener:
context.read<CommentsCubit>().itemPositionsListener,
itemCount: state.comments.length + 2,
padding: EdgeInsets.only(top: topPadding),
scrollOffsetListener: scrollOffsetListener,
@ -100,13 +101,16 @@ class MainView extends StatelessWidget {
index = index - 1;
final Comment comment = state.comments.elementAt(index);
return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
comment: comment,
index: index,
level: comment.level,
opUsername: state.item.by,
fetchMode: state.fetchMode,
isResponse: state.isResponse(comment),
onReplyTapped: (Comment cmt) {
HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) {
@ -130,7 +134,6 @@ class MainView extends StatelessWidget {
},
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
itemScrollController: itemScrollController,
),
);
},
@ -185,7 +188,7 @@ class _ParentItemSection extends StatelessWidget {
final ValueChanged<Comment> onRightMoreTapped;
static const double _viewParentButtonWidth = 100;
static const double _viewRootButtonWidth = 80;
static const double _viewRootButtonWidth = 85;
@override
Widget build(BuildContext context) {

View File

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
@ -66,175 +67,191 @@ class MorePopupMenu extends StatelessWidget {
builder: (BuildContext context, VoteState voteState) {
final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down;
return ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
BlocProvider<UserCubit>(
create: (BuildContext context) =>
UserCubit()..init(userId: item.by),
child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) {
return Semantics(
excludeSemantics: state.status == Status.inProgress,
child: ListTile(
leading: const Icon(
Icons.account_circle,
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
BlocProvider<UserCubit>(
create: (BuildContext context) =>
UserCubit()..init(userId: item.by),
child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) {
return Semantics(
excludeSemantics: state.status == Status.inProgress,
child: ListTile(
leading: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
AnimatedCrossFade(
alignment: Alignment.center,
duration: Durations.ms300,
crossFadeState: state.status.isLoading
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: const Icon(
Icons.account_circle_outlined,
),
secondChild: const Icon(
Icons.account_circle,
),
),
title: Text(item.by),
subtitle: Text(
state.user.description,
),
onTap: () {
context.pop();
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
semanticLabel:
'''About ${state.user.id}. ${state.user.about}''',
title: Text(
'About ${state.user.id}',
),
content: state.user.about.isEmpty
? const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
],
),
title: Text(item.by),
subtitle: Text(
state.user.description,
),
onTap: () {
context.pop();
final double fontSize = context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize;
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
semanticLabel:
'''About ${state.user.id}. ${state.user.about}''',
title: Text(
'About ${state.user.id}',
),
content: state.user.about.isEmpty
? const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
linkStyle: TextStyle(
color:
Theme.of(context).primaryColor,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(
link.url,
context,
),
semanticsLabel: state.user.about,
),
actions: <Widget>[
TextButton(
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
context.pop();
onSearchUserTapped(context);
},
child: const Text(
'Search',
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
),
),
TextButton(
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
context.pop();
},
child: const Text(
'Okay',
style: TextStyle(
fontSize: fontSize,
),
linkStyle: TextStyle(
fontSize: fontSize,
color: Theme.of(context).primaryColor,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(
link.url,
context,
),
semanticsLabel: state.user.about,
),
],
actions: <Widget>[
TextButton(
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
context.pop();
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
);
},
),
);
},
),
),
ListTile(
leading: Icon(
FeatherIcons.chevronUp,
color: upvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
upvoted ? 'Upvoted' : 'Upvote',
style: upvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
subtitle:
item is Story ? Text(item.score.toString()) : null,
onTap: context.read<VoteCubit>().upvote,
),
ListTile(
leading: Icon(
FeatherIcons.chevronDown,
color: downvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
downvoted ? 'Downvoted' : 'Downvote',
style: downvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Theme.of(context).primaryColor : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => context.pop(MenuAction.fav),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
'Share',
),
onTap: () => context.pop(MenuAction.share),
),
ListTile(
leading: const Icon(Icons.local_police),
title: const Text(
'Flag',
),
onTap: () => context.pop(MenuAction.flag),
),
ListTile(
leading: Icon(
isBlocked ? Icons.visibility : Icons.visibility_off,
),
title: Text(
isBlocked ? 'Unblock' : 'Block',
),
onTap: () => context.pop(MenuAction.block),
),
ListTile(
leading: const Icon(Icons.close),
title: const Text(
'Cancel',
),
onTap: () => context.pop(MenuAction.cancel),
),
],
TextButton(
onPressed: () {
locator
.get<AppReviewService>()
.requestReview();
context.pop();
},
child: const Text(
'Okay',
),
),
],
),
);
},
),
);
},
),
),
),
ListTile(
leading: Icon(
FeatherIcons.chevronUp,
color: upvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
upvoted ? 'Upvoted' : 'Upvote',
style: upvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
subtitle: item is Story ? Text(item.score.toString()) : null,
onTap: context.read<VoteCubit>().upvote,
),
ListTile(
leading: Icon(
FeatherIcons.chevronDown,
color: downvoted ? Theme.of(context).primaryColor : null,
),
title: Text(
downvoted ? 'Downvoted' : 'Downvote',
style: downvoted
? TextStyle(color: Theme.of(context).primaryColor)
: null,
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Theme.of(context).primaryColor : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => context.pop(MenuAction.fav),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
'Share',
),
onTap: () => context.pop(MenuAction.share),
),
ListTile(
leading: const Icon(Icons.local_police),
title: const Text(
'Flag',
),
onTap: () => context.pop(MenuAction.flag),
),
ListTile(
leading: Icon(
isBlocked ? Icons.visibility : Icons.visibility_off,
),
title: Text(
isBlocked ? 'Unblock' : 'Block',
),
onTap: () => context.pop(MenuAction.block),
),
ListTile(
leading: const Icon(Icons.close),
title: const Text(
'Cancel',
),
onTap: () => context.pop(MenuAction.cancel),
),
],
);
},
),
@ -245,6 +262,7 @@ class MorePopupMenu extends StatelessWidget {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return BlocProvider<SearchCubit>(
create: (_) => SearchCubit()
@ -253,28 +271,16 @@ class MorePopupMenu extends StatelessWidget {
author: item.by,
),
),
child: Container(
child: SizedBox(
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),
),
child: const Column(
children: <Widget>[
Expanded(
child: SearchScreen(
fromUserDialog: true,
),
const Expanded(
child: SearchScreen(
fromUserDialog: true,
),
),
],
),
),
],
),
),
);

View File

@ -76,7 +76,8 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
duration: Durations.ms200,
decoration: BoxDecoration(
boxShadow: <BoxShadow>[
if (!context.read<SplitViewCubit>().state.enabled)
if (!context.read<SplitViewCubit>().state.enabled &&
!Theme.of(context).useMaterial3)
BoxShadow(
color: expanded ? Palette.transparent : Palette.black26,
blurRadius: Dimens.pt40,
@ -84,6 +85,9 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
],
),
child: Material(
color: Theme.of(context).useMaterial3
? Palette.transparent
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[

View File

@ -1,6 +1,7 @@
export 'custom_app_bar.dart';
export 'custom_floating_action_button.dart';
export 'fav_icon_button.dart';
export 'in_thread_search_icon_button.dart';
export 'link_icon_button.dart';
export 'login_dialog.dart';
export 'main_view.dart';

View File

@ -12,14 +12,13 @@ class QrCodeViewScreen extends StatelessWidget {
static const String routeName = 'qr-code-view';
static const int qrCodeVersion = 4;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Palette.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -35,7 +34,6 @@ class QrCodeViewScreen extends StatelessWidget {
eyeShape: QrEyeShape.square,
color: Theme.of(context).colorScheme.onSurface,
),
version: qrCodeVersion,
size: 300,
),
),

View File

@ -102,15 +102,12 @@ class InboxView extends StatelessWidget {
),
Linkify(
text: e.text,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: unreadCommentsIds.contains(e.id)
? textColor
: Palette.grey,
fontSize: TextDimens.pt16,
),
style: TextStyle(
color: unreadCommentsIds.contains(e.id)
? textColor
: Palette.grey,
fontSize: TextDimens.pt16,
),
linkStyle: TextStyle(
color: Theme.of(context)
.primaryColor

View File

@ -30,17 +30,29 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
final RefreshController refreshController = RefreshController();
final ScrollController scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController();
final FocusNode focusNode = FocusNode();
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
static const Duration chipsAnimationDuration = Durations.ms300;
@override
void initState() {
super.initState();
scrollController.addListener(onScroll);
}
@override
void dispose() {
refreshController.dispose();
scrollController.dispose();
focusNode.dispose();
textEditingController.dispose();
super.dispose();
}
void onScroll() => focusNode.unfocus();
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -53,226 +65,12 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
},
builder: (BuildContext context, SearchState state) {
return Scaffold(
backgroundColor: Palette.transparent,
resizeToAvoidBottomInset: false,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ColoredBox(
color: Theme.of(context).canvasColor,
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: TextField(
cursorColor: Theme.of(context).primaryColor,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search Hacker News',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
),
),
),
onChanged: (String val) {
if (val.isNotEmpty) {
debouncer.run(() {
context.read<SearchCubit>().search(val);
});
}
},
),
),
const SizedBox(
height: Dimens.pt6,
),
AnimatedCrossFade(
duration: chipsAnimationDuration,
crossFadeState: state.showDateRangeShortcutChips
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: SizedBox.fromSize(),
secondChild: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
],
),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.dateFilter,
initialStartDate: state.dateFilter?.startTime,
initialEndDate: state.dateFilter?.endTime,
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
),
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged: context
.read<SearchCubit>()
.onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(),
selected: state.params.sorted,
label: '''newest first''',
),
const SizedBox(
width: Dimens.pt8,
),
for (final CustomDateTimeRange range
in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
),
],
],
),
),
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 &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
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(
child: SmartRefresher(
enablePullDown: false,
@ -313,6 +111,240 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
? const NeverScrollableScrollPhysics()
: null,
children: <Widget>[
Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: TextField(
controller: textEditingController,
focusNode: focusNode,
cursorColor: Theme.of(context).primaryColor,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search Hacker News',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
),
),
),
onChanged: (String val) {
if (val.isNotEmpty) {
debouncer.run(() {
context.read<SearchCubit>().search(val);
});
}
},
),
),
const SizedBox(
height: Dimens.pt6,
),
AnimatedCrossFade(
duration: chipsAnimationDuration,
crossFadeState: state.showDateRangeShortcutChips
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: SizedBox.fromSize(),
secondChild: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate:
state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
],
),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.dateFilter,
initialStartDate:
state.dateFilter?.startTime,
initialEndDate: state.dateFilter?.endTime,
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
),
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter:
state.params.get<PostedByFilter>(),
onChanged: context
.read<SearchCubit>()
.onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onSortToggled(),
selected: state.params.sorted,
label: '''newest first''',
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) => context
.read<SearchCubit>()
.onExactMatchToggled(),
selected: state.params.exactMatch,
label: '''exact match''',
),
const SizedBox(
width: Dimens.pt8,
),
for (final CustomDateTimeRange range
in CustomDateTimeRange
.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
),
],
],
),
),
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 &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
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,
),
),
),
],
],
),
...state.results
.map(
(Item e) => <Widget>[

View File

@ -49,7 +49,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
final DateTime? start = filter?.startTime;
final DateTime? end = filter?.endTime;
if (start == null && end == null) {
return '''from X to Y''';
return '''date range''';
} else if (start == end) {
return '''from ${_formatDateTime(start)}''';
} else {

View File

@ -51,7 +51,10 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
backgroundColor: Theme.of(context).canvasColor,
elevation: Dimens.zero,
leading: IconButton(
icon: const Icon(Icons.close),
icon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () {
showDialog<bool>(
context: context,
@ -83,8 +86,11 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
});
},
),
title: const Text(
title: Text(
'Submit',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
actions: <Widget>[
if (state.status == Status.inProgress)

View File

@ -20,6 +20,7 @@ class WebViewScreen extends StatefulWidget {
class _WebViewScreenState extends State<WebViewScreen> {
final WebViewController controller = WebViewController();
bool showFullUrl = false;
@override
void initState() {
@ -43,15 +44,26 @@ class _WebViewScreenState extends State<WebViewScreen> {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).canvasColor,
title: Text(
humanize(widget.url),
style: const TextStyle(
fontSize: TextDimens.pt12,
foregroundColor: Theme.of(context).colorScheme.onSurface,
title: GestureDetector(
onTap: () {
setState(() {
showFullUrl = !showFullUrl;
});
},
child: Text(
showFullUrl
? humanize(widget.url)
: Uri.parse(widget.url).authority,
style: const TextStyle(
fontSize: TextDimens.pt14,
),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
centerTitle: true,
elevation: 0,
),
body: WebViewWidget(
controller: controller,

View File

@ -32,6 +32,12 @@ class CenteredText extends StatelessWidget {
text: 'blocked',
);
const CenteredText.empty({Key? key})
: this(
key: key,
text: 'empty',
);
final String text;
final Color color;

View File

@ -10,7 +10,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CommentTile extends StatelessWidget {
const CommentTile({
@ -25,19 +24,21 @@ class CommentTile extends StatelessWidget {
this.actionable = true,
this.collapsable = true,
this.selectable = true,
this.isResponse = false,
this.level = 0,
this.index,
this.onTap,
this.itemScrollController,
});
final String? opUsername;
final Comment comment;
final int level;
final int? index;
final bool actionable;
final bool collapsable;
final bool selectable;
final bool isResponse;
final FetchMode fetchMode;
final ItemScrollController? itemScrollController;
final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped;
@ -163,6 +164,24 @@ class CommentTile extends StatelessWidget {
color: primaryColor,
),
),
if (index != null)
Text(
' #${index! + 1}',
style: const TextStyle(
color: Palette.grey,
),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
if (isResponse)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(
Icons.reply,
size: 16,
color: Palette.grey,
),
),
const Spacer(),
Text(
comment.timeAgo,
@ -351,7 +370,8 @@ class CommentTile extends StatelessWidget {
final CollapseState collapseState = context.read<CollapseCubit>().state;
final CommentsState? commentsState =
context.tryRead<CommentsCubit>()?.state;
return fetchMode == FetchMode.lazy &&
return actionable &&
fetchMode == FetchMode.lazy &&
comment.kids.isNotEmpty &&
collapseState.collapsed == false &&
commentsState?.commentIds.contains(comment.kids.first) == false &&
@ -370,14 +390,14 @@ class CommentTile extends StatelessWidget {
..collapse(onStateChanged: HapticFeedbackUtil.selection);
if (collapseCubit.state.collapsed &&
preferenceCubit.state.autoScrollEnabled) {
final List<Comment> comments =
context.read<CommentsCubit>().state.comments;
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
final List<Comment> comments = commentsCubit.state.comments;
final int indexOfNextComment = comments.indexOf(comment) + 1;
if (indexOfNextComment < comments.length) {
Future<void>.delayed(
Durations.ms300,
() {
itemScrollController?.scrollTo(
commentsCubit.itemScrollController.scrollTo(
index: indexOfNextComment,
alignment: 0.1,
duration: Durations.ms300,

View File

@ -15,13 +15,19 @@ class CustomChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final bool useMaterial3 = Theme.of(context).useMaterial3;
return FilterChip(
shadowColor: Palette.transparent,
selectedShadowColor: Palette.transparent,
backgroundColor: Palette.transparent,
shape: StadiumBorder(
side: BorderSide(color: Theme.of(context).primaryColor),
),
side: useMaterial3 && !selected
? BorderSide(color: Theme.of(context).colorScheme.onSurface)
: null,
shape: Theme.of(context).useMaterial3
? null
: StadiumBorder(
side: BorderSide(color: Theme.of(context).primaryColor),
),
label: Text(label),
labelStyle: TextStyle(
color: selected ? Theme.of(context).colorScheme.onPrimary : null,

View File

@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart' show StringCharacters, immutable;
import 'package:linkify/linkify.dart';
final RegExp _urlRegex = RegExp(
r'^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,~-]*)',
r'''^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,'~-]*)''',
caseSensitive: false,
dotAll: true,
);

View File

@ -25,10 +25,12 @@ class ItemsListView<T extends Item> extends StatelessWidget {
this.enablePullDown = true,
this.markReadStories = false,
this.showOfflineBanner = false,
this.loadStyle = LoadStyle.ShowWhenLoading,
this.onRefresh,
this.onLoadMore,
this.onPinned,
this.header,
this.footer,
this.onMoreTapped,
this.scrollController,
this.itemBuilder,
@ -43,8 +45,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool markReadStories;
final bool showOfflineBanner;
final LoadStyle loadStyle;
final List<T> items;
final Widget? header;
final Widget? footer;
final RefreshController refreshController;
final ScrollController? scrollController;
final VoidCallback? onRefresh;
@ -105,10 +109,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
Linkify(
text: e.title,
maxLines: 4,
style:
Theme.of(context).textTheme.bodyLarge?.copyWith(
fontSize: TextDimens.pt16,
),
style: const TextStyle(
fontSize: TextDimens.pt16,
),
linkStyle: TextStyle(
color: Theme.of(context).primaryColor,
),
@ -141,7 +144,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
),
),
),
if (!showWebPreviewOnStoryTile)
if (useSimpleTileForStory || !showWebPreviewOnStoryTile)
const Divider(
height: Dimens.zero,
),
@ -195,12 +198,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
Linkify(
text: e.text,
maxLines: 4,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
fontSize: TextDimens.pt16,
),
style: const TextStyle(
fontSize: TextDimens.pt16,
),
linkStyle: TextStyle(
color: Theme.of(context).primaryColor,
),
@ -228,6 +228,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
? Column(children: e)
: itemBuilder!(Column(children: e), items.elementAt(index)),
),
if (footer != null) footer!,
const SizedBox(
height: Dimens.pt40,
),
@ -241,7 +242,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
backgroundColor: Theme.of(context).primaryColor,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
loadStyle: loadStyle,
builder: (BuildContext context, LoadStatus? mode) {
const double height = 55;
late final Widget body;

View File

@ -22,6 +22,7 @@ class OfflineBanner extends StatelessWidget {
builder: (BuildContext context, StoriesState state) {
if (state.isOfflineReading) {
return MaterialBanner(
dividerColor: Palette.transparent,
content: Text(
'You are currently in offline mode. '
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',

View File

@ -3,10 +3,12 @@ import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:visibility_detector/visibility_detector.dart';
@ -48,7 +50,8 @@ class _StoriesListViewState extends State<StoriesListView>
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled,
previous.metadataEnabled != current.metadataEnabled ||
previous.manualPaginationEnabled != current.manualPaginationEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (StoriesState previous, StoriesState current) =>
@ -66,12 +69,21 @@ class _StoriesListViewState extends State<StoriesListView>
previous.currentPageByType[storyType] == 0) ||
(previous.storiesByType[storyType]!.length !=
current.storiesByType[storyType]!.length) ||
(previous.readStoriesIds.length != current.readStoriesIds.length),
(previous.readStoriesIds.length !=
current.readStoriesIds.length) ||
(previous.statusByType[widget.storyType] !=
current.statusByType[widget.storyType]),
builder: (BuildContext context, StoriesState state) {
bool shouldShowLoadButton() {
return preferenceState.manualPaginationEnabled &&
state.statusByType[widget.storyType] == Status.success &&
(state.storiesByType[widget.storyType]?.length ?? 0) <
(state.storyIdsByType[widget.storyType]?.length ?? 0);
}
return ItemsListView<Story>(
showOfflineBanner: true,
markReadStories:
context.read<PreferenceCubit>().state.markReadStoriesEnabled,
markReadStories: preferenceState.markReadStoriesEnabled,
showWebPreviewOnStoryTile:
preferenceState.complexStoryTileEnabled,
showMetadataOnStoryTile: preferenceState.metadataEnabled,
@ -87,13 +99,50 @@ class _StoriesListViewState extends State<StoriesListView>
context.read<PinCubit>().refresh();
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: storyType));
if (preferenceState.manualPaginationEnabled) {
refreshController
..refreshCompleted(resetFooterState: true)
..loadComplete();
} else {
loadMoreStories();
}
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: state.isOfflineReading ? null : header,
loadStyle: LoadStyle.HideAlways,
footer: Center(
child: AnimatedCrossFade(
alignment: Alignment.center,
crossFadeState: shouldShowLoadButton()
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: Durations.ms300,
firstChild: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt48,
right: Dimens.pt48,
top: Dimens.pt36,
bottom: Dimens.pt12,
),
child: OutlinedButton(
onPressed: loadMoreStories,
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(double.infinity, Dimens.pt48),
),
foregroundColor: MaterialStateColor.resolveWith(
(_) => Theme.of(context).colorScheme.onSurface,
),
),
child: Text(
'''Load Page ${(state.currentPageByType[widget.storyType] ?? 0) + 2}''',
),
),
),
secondChild: const SizedBox.shrink(),
),
),
onMoreTapped: onMoreTapped,
itemBuilder: (Widget child, Story story) {
return Slidable(
@ -162,4 +211,7 @@ class _StoriesListViewState extends State<StoriesListView>
},
);
}
void loadMoreStories() =>
context.read<StoriesBloc>().add(StoriesLoadMore(type: widget.storyType));
}

View File

@ -96,6 +96,7 @@ class StoryTile extends StatelessWidget {
.textTheme
.bodyLarge
?.color,
fontWeight: hasRead ? null : FontWeight.w500,
fontSize: simpleTileFontSize,
),
),

View File

@ -37,6 +37,7 @@ class _TapDownWrapperState extends State<TapDownWrapper>
onTapDown: onTapDown,
onTapUp: onTapUp,
onTapCancel: onTapCancel,
behavior: HitTestBehavior.opaque,
child: AnimatedBuilder(
animation:
CurvedAnimation(parent: controller, curve: Curves.decelerate),

View File

@ -1,7 +1,17 @@
import 'package:flutter/services.dart';
abstract class HapticFeedbackUtil {
static void selection() => HapticFeedback.selectionClick();
static bool enabled = true;
static void light() => HapticFeedback.lightImpact();
static void selection() {
if (enabled) {
HapticFeedback.selectionClick();
}
}
static void light() {
if (enabled) {
HapticFeedback.lightImpact();
}
}
}

View File

@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.13.0"
animations:
dependency: "direct main"
description:
name: animations
sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70
url: "https://pub.dev"
source: hosted
version: "2.0.8"
args:
dependency: transitive
description:

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 2.0.0+125
version: 2.3.1+130
publish_to: none
environment:
@ -9,6 +9,7 @@ environment:
dependencies:
adaptive_theme: ^3.2.0
animations: ^2.0.8
badges: ^3.0.2
bloc: ^8.1.1
cached_network_image: ^3.2.3