mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
0310507c96 | |||
58c646e232 | |||
08328e2ca1 | |||
86b7228ffd | |||
e103c88ca6 | |||
94323a04e0 | |||
4776c375a1 | |||
1f4e6cf41c | |||
be6ed35888 | |||
b2ea50cea6 | |||
109b9287cf | |||
939d55ef0d | |||
3ee60e1a44 | |||
6fe567fa02 | |||
bc2d4f32c9 | |||
91290e9743 | |||
934f184b6f | |||
dbd48eae99 | |||
279007191b | |||
b3fdc20fc5 |
@ -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
|
# <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.
|
||||||
|
|
||||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||||
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
[](https://f-droid.org/en/packages/com.jiaqifeng.hacki/)
|
||||||
|
4
fastlane/metadata/android/en-US/changelogs/127.txt
Normal file
4
fastlane/metadata/android/en-US/changelogs/127.txt
Normal 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.
|
5
fastlane/metadata/android/en-US/changelogs/128.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/128.txt
Normal 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.
|
5
fastlane/metadata/android/en-US/changelogs/129.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/129.txt
Normal 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.
|
@ -20,6 +20,8 @@ abstract class Constants {
|
|||||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
'$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 wikipediaLink = 'https://en.wikipedia.org/wiki/';
|
||||||
static const String wiktionaryLink = 'https://en.wiktionary.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 supportEmail = 'georgefung98@gmail.com';
|
||||||
|
|
||||||
static const String _imagePath = 'assets/images';
|
static const String _imagePath = 'assets/images';
|
||||||
|
@ -9,6 +9,7 @@ import 'package:hacki/config/constants.dart';
|
|||||||
import 'package:hacki/config/custom_router.dart';
|
import 'package:hacki/config/custom_router.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/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/repositories/repositories.dart';
|
import 'package:hacki/repositories/repositories.dart';
|
||||||
import 'package:hacki/screens/screens.dart';
|
import 'package:hacki/screens/screens.dart';
|
||||||
@ -61,6 +62,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
final SembastRepository _sembastRepository;
|
final SembastRepository _sembastRepository;
|
||||||
final Logger _logger;
|
final Logger _logger;
|
||||||
|
|
||||||
|
final ItemScrollController itemScrollController = ItemScrollController();
|
||||||
|
final ItemPositionsListener itemPositionsListener =
|
||||||
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
/// The [StreamSubscription] for stream (both lazy or eager)
|
/// The [StreamSubscription] for stream (both lazy or eager)
|
||||||
/// fetching comments posted directly to the story.
|
/// fetching comments posted directly to the story.
|
||||||
StreamSubscription<Comment>? _streamSubscription;
|
StreamSubscription<Comment>? _streamSubscription;
|
||||||
@ -108,6 +113,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: CommentsStatus.inProgress,
|
status: CommentsStatus.inProgress,
|
||||||
comments: <Comment>[],
|
comments: <Comment>[],
|
||||||
|
matchedComments: <int>[],
|
||||||
|
inThreadSearchQuery: '',
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -213,6 +220,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
onlyShowTargetComment: false,
|
onlyShowTargetComment: false,
|
||||||
item: story,
|
item: story,
|
||||||
|
matchedComments: <int>[],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
init();
|
init();
|
||||||
@ -349,11 +357,20 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
init(useCommentCache: true);
|
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.
|
/// Scroll to next root level comment.
|
||||||
void scrollToNextRoot(
|
void scrollToNextRoot({VoidCallback? onError}) {
|
||||||
ItemScrollController itemScrollController,
|
|
||||||
ItemPositionsListener itemPositionsListener,
|
|
||||||
) {
|
|
||||||
final int totalComments = state.comments.length;
|
final int totalComments = state.comments.length;
|
||||||
final List<Comment> onScreenComments = itemPositionsListener
|
final List<Comment> onScreenComments = itemPositionsListener
|
||||||
.itemPositions.value
|
.itemPositions.value
|
||||||
@ -378,10 +395,20 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
return;
|
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.
|
/// The index of first root level comment visible on screen.
|
||||||
final int firstVisibleRootIndex = state.comments
|
final int firstVisibleRootCommentIndex =
|
||||||
.indexOf(onScreenComments.firstWhere((Comment e) => e.isRoot));
|
state.comments.indexOf(firstVisibleRootComment);
|
||||||
final int startIndex = min(firstVisibleRootIndex + 1, totalComments);
|
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++) {
|
for (int i = startIndex; i < totalComments; i++) {
|
||||||
final Comment cmt = state.comments.elementAt(i);
|
final Comment cmt = state.comments.elementAt(i);
|
||||||
@ -395,13 +422,14 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.status == CommentsStatus.allLoaded) {
|
||||||
|
onError?.call();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll to previous root level comment.
|
/// Scroll to previous root level comment.
|
||||||
void scrollToPreviousRoot(
|
void scrollToPreviousRoot() {
|
||||||
ItemScrollController itemScrollController,
|
|
||||||
ItemPositionsListener itemPositionsListener,
|
|
||||||
) {
|
|
||||||
final List<Comment> onScreenComments = itemPositionsListener
|
final List<Comment> onScreenComments = itemPositionsListener
|
||||||
.itemPositions.value
|
.itemPositions.value
|
||||||
// The header is also a part of the list view,
|
// The header is also a part of the list view,
|
||||||
@ -436,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) {
|
List<int> _sortKids(List<int> kids) {
|
||||||
switch (state.order) {
|
switch (state.order) {
|
||||||
case CommentsOrder.natural:
|
case CommentsOrder.natural:
|
||||||
@ -463,6 +535,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
_commentCache.cacheComment(comment);
|
_commentCache.cacheComment(comment);
|
||||||
_sembastRepository.cacheComment(comment);
|
_sembastRepository.cacheComment(comment);
|
||||||
|
|
||||||
|
// Hide comment that matches any of the filter keywords.
|
||||||
final bool hidden = _filterCubit.state.keywords.any(
|
final bool hidden = _filterCubit.state.keywords.any(
|
||||||
(String keyword) => comment.text.toLowerCase().contains(keyword),
|
(String keyword) => comment.text.toLowerCase().contains(keyword),
|
||||||
);
|
);
|
||||||
@ -471,7 +544,16 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
comment.copyWith(hidden: hidden),
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,6 +12,8 @@ class CommentsState extends Equatable {
|
|||||||
const CommentsState({
|
const CommentsState({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.comments,
|
required this.comments,
|
||||||
|
required this.matchedComments,
|
||||||
|
required this.idToCommentMap,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.fetchParentStatus,
|
required this.fetchParentStatus,
|
||||||
required this.fetchRootStatus,
|
required this.fetchRootStatus,
|
||||||
@ -20,6 +22,8 @@ class CommentsState extends Equatable {
|
|||||||
required this.onlyShowTargetComment,
|
required this.onlyShowTargetComment,
|
||||||
required this.isOfflineReading,
|
required this.isOfflineReading,
|
||||||
required this.currentPage,
|
required this.currentPage,
|
||||||
|
required this.inThreadSearchQuery,
|
||||||
|
required this.inThreadSearchAuthor,
|
||||||
});
|
});
|
||||||
|
|
||||||
CommentsState.init({
|
CommentsState.init({
|
||||||
@ -28,14 +32,19 @@ class CommentsState extends Equatable {
|
|||||||
required this.fetchMode,
|
required this.fetchMode,
|
||||||
required this.order,
|
required this.order,
|
||||||
}) : comments = <Comment>[],
|
}) : comments = <Comment>[],
|
||||||
|
matchedComments = <int>[],
|
||||||
|
idToCommentMap = <int, Comment>{},
|
||||||
status = CommentsStatus.idle,
|
status = CommentsStatus.idle,
|
||||||
fetchParentStatus = CommentsStatus.idle,
|
fetchParentStatus = CommentsStatus.idle,
|
||||||
fetchRootStatus = CommentsStatus.idle,
|
fetchRootStatus = CommentsStatus.idle,
|
||||||
onlyShowTargetComment = false,
|
onlyShowTargetComment = false,
|
||||||
currentPage = 0;
|
currentPage = 0,
|
||||||
|
inThreadSearchQuery = '',
|
||||||
|
inThreadSearchAuthor = '';
|
||||||
|
|
||||||
final Item item;
|
final Item item;
|
||||||
final List<Comment> comments;
|
final List<Comment> comments;
|
||||||
|
final Map<int, Comment> idToCommentMap;
|
||||||
final CommentsStatus status;
|
final CommentsStatus status;
|
||||||
final CommentsStatus fetchParentStatus;
|
final CommentsStatus fetchParentStatus;
|
||||||
final CommentsStatus fetchRootStatus;
|
final CommentsStatus fetchRootStatus;
|
||||||
@ -44,10 +53,17 @@ class CommentsState extends Equatable {
|
|||||||
final bool onlyShowTargetComment;
|
final bool onlyShowTargetComment;
|
||||||
final bool isOfflineReading;
|
final bool isOfflineReading;
|
||||||
final int currentPage;
|
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({
|
CommentsState copyWith({
|
||||||
Item? item,
|
Item? item,
|
||||||
List<Comment>? comments,
|
List<Comment>? comments,
|
||||||
|
List<int>? matchedComments,
|
||||||
|
Map<int, Comment>? idToCommentMap,
|
||||||
CommentsStatus? status,
|
CommentsStatus? status,
|
||||||
CommentsStatus? fetchParentStatus,
|
CommentsStatus? fetchParentStatus,
|
||||||
CommentsStatus? fetchRootStatus,
|
CommentsStatus? fetchRootStatus,
|
||||||
@ -56,10 +72,13 @@ class CommentsState extends Equatable {
|
|||||||
bool? onlyShowTargetComment,
|
bool? onlyShowTargetComment,
|
||||||
bool? isOfflineReading,
|
bool? isOfflineReading,
|
||||||
int? currentPage,
|
int? currentPage,
|
||||||
|
String? inThreadSearchQuery,
|
||||||
|
String? inThreadSearchAuthor,
|
||||||
}) {
|
}) {
|
||||||
return CommentsState(
|
return CommentsState(
|
||||||
item: item ?? this.item,
|
item: item ?? this.item,
|
||||||
comments: comments ?? this.comments,
|
comments: comments ?? this.comments,
|
||||||
|
matchedComments: matchedComments ?? this.matchedComments,
|
||||||
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
|
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
|
||||||
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
|
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@ -69,11 +88,41 @@ class CommentsState extends Equatable {
|
|||||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||||
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
||||||
currentPage: currentPage ?? this.currentPage,
|
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();
|
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
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
item,
|
item,
|
||||||
@ -86,5 +135,9 @@ class CommentsState extends Equatable {
|
|||||||
isOfflineReading,
|
isOfflineReading,
|
||||||
currentPage,
|
currentPage,
|
||||||
comments,
|
comments,
|
||||||
|
matchedComments,
|
||||||
|
inThreadSearchQuery,
|
||||||
|
inThreadSearchAuthor,
|
||||||
|
idToCommentMap,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -70,6 +70,14 @@ class PreferenceState extends Equatable {
|
|||||||
|
|
||||||
bool get customTabEnabled => _isOn<CustomTabPreference>();
|
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 =>
|
double get textScaleFactor =>
|
||||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||||
|
|
||||||
|
@ -102,6 +102,18 @@ class SearchCubit extends Cubit<SearchState> {
|
|||||||
search(state.params.query);
|
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) {
|
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
|
||||||
final DateTime updatedStart = start.copyWith(
|
final DateTime updatedStart = start.copyWith(
|
||||||
second: 0,
|
second: 0,
|
||||||
|
@ -85,4 +85,8 @@ extension ContextExtension on BuildContext {
|
|||||||
int get storyTileMaxLines {
|
int get storyTileMaxLines {
|
||||||
return _storyTileMaxLines;
|
return _storyTileMaxLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double get topPadding {
|
||||||
|
return MediaQuery.of(this).padding.top + kToolbarHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||||
|
import 'package:hacki/config/constants.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';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
@ -103,9 +104,6 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ColoredBox(
|
|
||||||
color: Theme.of(context).canvasColor,
|
|
||||||
child: Material(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@ -115,19 +113,17 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
onTap: () => context.pop(
|
onTap: () => context.pop(
|
||||||
'https://news.ycombinator.com/item?id=${item.id}',
|
'${Constants.hackerNewsItemLinkPrefix}${item.id}',
|
||||||
),
|
),
|
||||||
title: const Text('Link to HN'),
|
title: const Text('Link to HN'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
|
linkToShare = '${Constants.hackerNewsItemLinkPrefix}${item.id}';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linkToShare != null) {
|
if (linkToShare != null) {
|
||||||
|
@ -19,6 +19,7 @@ import 'package:hacki/config/locator.dart';
|
|||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/services/fetcher.dart';
|
import 'package:hacki/services/fetcher.dart';
|
||||||
import 'package:hacki/styles/styles.dart';
|
import 'package:hacki/styles/styles.dart';
|
||||||
|
import 'package:hacki/utils/haptic_feedback_util.dart';
|
||||||
import 'package:hacki/utils/theme_util.dart';
|
import 'package:hacki/utils/theme_util.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
@ -229,14 +230,23 @@ class HackiApp extends StatelessWidget {
|
|||||||
)..init(),
|
)..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) =>
|
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||||
previous.appColor != current.appColor ||
|
previous.appColor != current.appColor ||
|
||||||
previous.font != current.font ||
|
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) {
|
builder: (BuildContext context, PreferenceState state) {
|
||||||
return AdaptiveTheme(
|
return AdaptiveTheme(
|
||||||
key: ValueKey<String>('${state.appColor}${state.font}'),
|
key: ValueKey<String>(
|
||||||
|
'''${state.appColor}${state.font}${state.material3Enabled}${state.trueDarkModeEnabled}''',
|
||||||
|
),
|
||||||
light: ThemeData(
|
light: ThemeData(
|
||||||
primaryColor: state.appColor,
|
primaryColor: state.appColor,
|
||||||
colorScheme: ColorScheme.fromSwatch(
|
colorScheme: ColorScheme.fromSwatch(
|
||||||
@ -251,7 +261,7 @@ class HackiApp extends StatelessWidget {
|
|||||||
primarySwatch: state.appColor,
|
primarySwatch: state.appColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
canvasColor: Palette.black,
|
canvasColor: state.trueDarkModeEnabled ? Palette.black : null,
|
||||||
fontFamily: state.font.name,
|
fontFamily: state.font.name,
|
||||||
),
|
),
|
||||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||||
@ -287,7 +297,61 @@ class HackiApp extends StatelessWidget {
|
|||||||
title: 'Hacki',
|
title: 'Hacki',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
|
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,
|
routerConfig: router,
|
||||||
),
|
),
|
||||||
|
@ -25,13 +25,18 @@ enum DiscoverableFeature {
|
|||||||
featureId: 'jump_up_button_with_long_press',
|
featureId: 'jump_up_button_with_long_press',
|
||||||
title: 'Shortcut',
|
title: 'Shortcut',
|
||||||
description:
|
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(
|
jumpDownButton(
|
||||||
featureId: 'jump_down_button_with_long_press',
|
featureId: 'jump_down_button_with_long_press',
|
||||||
title: 'Shortcut',
|
title: 'Shortcut',
|
||||||
description:
|
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({
|
const DiscoverableFeature({
|
||||||
|
@ -37,12 +37,16 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
|||||||
const MarkReadStoriesModePreference(),
|
const MarkReadStoriesModePreference(),
|
||||||
// Divider.
|
// Divider.
|
||||||
const NotificationModePreference(),
|
const NotificationModePreference(),
|
||||||
const SwipeGesturePreference(),
|
|
||||||
const AutoScrollModePreference(),
|
const AutoScrollModePreference(),
|
||||||
const CollapseModePreference(),
|
const CollapseModePreference(),
|
||||||
const ReaderModePreference(),
|
const ReaderModePreference(),
|
||||||
const CustomTabPreference(),
|
const CustomTabPreference(),
|
||||||
|
const ManualPaginationPreference(),
|
||||||
|
const SwipeGesturePreference(),
|
||||||
|
const HapticFeedbackPreference(),
|
||||||
const EyeCandyModePreference(),
|
const EyeCandyModePreference(),
|
||||||
|
const TrueDarkModePreference(),
|
||||||
|
const Material3Preference(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -66,6 +70,8 @@ const bool _notificationModeDefaultValue = true;
|
|||||||
const bool _swipeGestureModeDefaultValue = false;
|
const bool _swipeGestureModeDefaultValue = false;
|
||||||
const bool _displayModeDefaultValue = true;
|
const bool _displayModeDefaultValue = true;
|
||||||
const bool _eyeCandyModeDefaultValue = false;
|
const bool _eyeCandyModeDefaultValue = false;
|
||||||
|
const bool _trueDarkModeDefaultValue = false;
|
||||||
|
const bool _hapticFeedbackModeDefaultValue = true;
|
||||||
const bool _readerModeDefaultValue = true;
|
const bool _readerModeDefaultValue = true;
|
||||||
const bool _markReadStoriesModeDefaultValue = true;
|
const bool _markReadStoriesModeDefaultValue = true;
|
||||||
const bool _metadataModeDefaultValue = true;
|
const bool _metadataModeDefaultValue = true;
|
||||||
@ -73,6 +79,8 @@ const bool _storyUrlModeDefaultValue = true;
|
|||||||
const bool _collapseModeDefaultValue = true;
|
const bool _collapseModeDefaultValue = true;
|
||||||
const bool _autoScrollModeDefaultValue = false;
|
const bool _autoScrollModeDefaultValue = false;
|
||||||
const bool _customTabModeDefaultValue = false;
|
const bool _customTabModeDefaultValue = false;
|
||||||
|
const bool _material3ModeDefaultValue = false;
|
||||||
|
const bool _paginationModeDefaultValue = false;
|
||||||
const double _textScaleFactorDefaultValue = 1;
|
const double _textScaleFactorDefaultValue = 1;
|
||||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||||
@ -97,7 +105,7 @@ class SwipeGesturePreference extends BooleanPreference {
|
|||||||
String get key => 'swipeGestureMode';
|
String get key => 'swipeGestureMode';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get title => 'Enable Swipe Gesture';
|
String get title => 'Swipe Gesture';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get subtitle =>
|
String get subtitle =>
|
||||||
@ -285,6 +293,45 @@ class EyeCandyModePreference extends BooleanPreference {
|
|||||||
String get subtitle => 'some sort of magic.';
|
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.
|
/// Whether or not to use Custom Tabs for launching URLs.
|
||||||
/// If false, default browser will be used.
|
/// If false, default browser will be used.
|
||||||
///
|
///
|
||||||
@ -312,6 +359,47 @@ class CustomTabPreference extends BooleanPreference {
|
|||||||
bool get isDisplayable => Platform.isAndroid;
|
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 {
|
class FetchModePreference extends IntPreference {
|
||||||
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
|
FetchModePreference({int? val}) : super(val: val ?? _fetchModeDefaultValue);
|
||||||
|
|
||||||
@ -401,7 +489,7 @@ class StoryMarkingModePreference extends IntPreference {
|
|||||||
String get key => 'storyMarkingMode';
|
String get key => 'storyMarkingMode';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get title => 'Mark a Story as Read on';
|
String get title => 'Mark as Read on';
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppColorPreference extends IntPreference {
|
class AppColorPreference extends IntPreference {
|
||||||
|
@ -8,31 +8,36 @@ class SearchParams extends Equatable {
|
|||||||
required this.filters,
|
required this.filters,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.page,
|
required this.page,
|
||||||
this.sorted = false,
|
required this.sorted,
|
||||||
|
required this.exactMatch,
|
||||||
});
|
});
|
||||||
|
|
||||||
SearchParams.init()
|
SearchParams.init()
|
||||||
: filters = <SearchFilter>{},
|
: filters = <SearchFilter>{},
|
||||||
query = '',
|
query = '',
|
||||||
page = 0,
|
page = 0,
|
||||||
sorted = false;
|
sorted = false,
|
||||||
|
exactMatch = false;
|
||||||
|
|
||||||
final Set<SearchFilter> filters;
|
final Set<SearchFilter> filters;
|
||||||
final String query;
|
final String query;
|
||||||
final int page;
|
final int page;
|
||||||
final bool sorted;
|
final bool sorted;
|
||||||
|
final bool exactMatch;
|
||||||
|
|
||||||
SearchParams copyWith({
|
SearchParams copyWith({
|
||||||
Set<SearchFilter>? filters,
|
Set<SearchFilter>? filters,
|
||||||
String? query,
|
String? query,
|
||||||
int? page,
|
int? page,
|
||||||
bool? sorted,
|
bool? sorted,
|
||||||
|
bool? exactMatch,
|
||||||
}) {
|
}) {
|
||||||
return SearchParams(
|
return SearchParams(
|
||||||
filters: filters ?? this.filters,
|
filters: filters ?? this.filters,
|
||||||
query: query ?? this.query,
|
query: query ?? this.query,
|
||||||
page: page ?? this.page,
|
page: page ?? this.page,
|
||||||
sorted: sorted ?? this.sorted,
|
sorted: sorted ?? this.sorted,
|
||||||
|
exactMatch: exactMatch ?? this.exactMatch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,6 +48,7 @@ class SearchParams extends Equatable {
|
|||||||
query: query,
|
query: query,
|
||||||
page: page,
|
page: page,
|
||||||
sorted: sorted,
|
sorted: sorted,
|
||||||
|
exactMatch: exactMatch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,16 +60,19 @@ class SearchParams extends Equatable {
|
|||||||
query: query,
|
query: query,
|
||||||
page: page,
|
page: page,
|
||||||
sorted: sorted,
|
sorted: sorted,
|
||||||
|
exactMatch: exactMatch,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get filteredQuery {
|
String get filteredQuery {
|
||||||
final StringBuffer buffer = StringBuffer();
|
final StringBuffer buffer = StringBuffer();
|
||||||
|
final String encodedQuery =
|
||||||
|
Uri.encodeComponent(exactMatch ? '"$query"' : query);
|
||||||
|
|
||||||
if (sorted) {
|
if (sorted) {
|
||||||
buffer.write('search_by_date?query=${Uri.encodeComponent(query)}');
|
buffer.write('search_by_date?query=$encodedQuery');
|
||||||
} else {
|
} else {
|
||||||
buffer.write('search?query=${Uri.encodeComponent(query)}');
|
buffer.write('search?query=$encodedQuery');
|
||||||
}
|
}
|
||||||
|
|
||||||
final Iterable<NumericFilter> numericFilters =
|
final Iterable<NumericFilter> numericFilters =
|
||||||
@ -111,5 +120,6 @@ class SearchParams extends Equatable {
|
|||||||
query,
|
query,
|
||||||
page,
|
page,
|
||||||
sorted,
|
sorted,
|
||||||
|
exactMatch,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
super.didPopNext();
|
super.didPopNext();
|
||||||
if (context.read<StoriesBloc>().deviceScreenType ==
|
if (context.read<StoriesBloc>().deviceScreenType ==
|
||||||
DeviceScreenType.mobile) {
|
DeviceScreenType.mobile) {
|
||||||
locator.get<Logger>().i('resetting comments in CommentCache');
|
locator.get<Logger>().i('Resetting comments in CommentCache');
|
||||||
Future<void>.delayed(
|
Future<void>.delayed(
|
||||||
Durations.ms500,
|
Durations.ms500,
|
||||||
locator.get<CommentCache>().resetComments,
|
locator.get<CommentCache>().resetComments,
|
||||||
|
@ -142,9 +142,6 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
final TextEditingController commentEditingController =
|
final TextEditingController commentEditingController =
|
||||||
TextEditingController();
|
TextEditingController();
|
||||||
final FocusNode focusNode = FocusNode();
|
final FocusNode focusNode = FocusNode();
|
||||||
final ItemScrollController itemScrollController = ItemScrollController();
|
|
||||||
final ItemPositionsListener itemPositionsListener =
|
|
||||||
ItemPositionsListener.create();
|
|
||||||
final ScrollOffsetListener scrollOffsetListener =
|
final ScrollOffsetListener scrollOffsetListener =
|
||||||
ScrollOffsetListener.create();
|
ScrollOffsetListener.create();
|
||||||
final Throttle storyLinkTapThrottle = Throttle(
|
final Throttle storyLinkTapThrottle = Throttle(
|
||||||
@ -182,6 +179,7 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
FeatureDiscovery.discoverFeatures(
|
FeatureDiscovery.discoverFeatures(
|
||||||
context,
|
context,
|
||||||
<String>{
|
<String>{
|
||||||
|
DiscoverableFeature.searchInThread.featureId,
|
||||||
DiscoverableFeature.pinToTop.featureId,
|
DiscoverableFeature.pinToTop.featureId,
|
||||||
DiscoverableFeature.addStoryToFavList.featureId,
|
DiscoverableFeature.addStoryToFavList.featureId,
|
||||||
DiscoverableFeature.openStoryInWebView.featureId,
|
DiscoverableFeature.openStoryInWebView.featureId,
|
||||||
@ -218,8 +216,6 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final double topPadding =
|
|
||||||
MediaQuery.of(context).padding.top + kToolbarHeight;
|
|
||||||
return BlocBuilder<AuthBloc, AuthState>(
|
return BlocBuilder<AuthBloc, AuthState>(
|
||||||
builder: (BuildContext context, AuthState authState) {
|
builder: (BuildContext context, AuthState authState) {
|
||||||
return MultiBlocListener(
|
return MultiBlocListener(
|
||||||
@ -272,12 +268,10 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: MainView(
|
child: MainView(
|
||||||
itemScrollController: itemScrollController,
|
|
||||||
itemPositionsListener: itemPositionsListener,
|
|
||||||
scrollOffsetListener: scrollOffsetListener,
|
scrollOffsetListener: scrollOffsetListener,
|
||||||
commentEditingController: commentEditingController,
|
commentEditingController: commentEditingController,
|
||||||
authState: authState,
|
authState: authState,
|
||||||
topPadding: topPadding,
|
topPadding: context.topPadding,
|
||||||
splitViewEnabled: widget.splitViewEnabled,
|
splitViewEnabled: widget.splitViewEnabled,
|
||||||
onMoreTapped: onMoreTapped,
|
onMoreTapped: onMoreTapped,
|
||||||
onRightMoreTapped: onRightMoreTapped,
|
onRightMoreTapped: onRightMoreTapped,
|
||||||
@ -313,24 +307,24 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Positioned(
|
const Positioned(
|
||||||
right: Dimens.pt12,
|
right: Dimens.pt12,
|
||||||
bottom: Dimens.pt36,
|
bottom: Dimens.pt36,
|
||||||
child: CustomFloatingActionButton(
|
child: CustomFloatingActionButton(),
|
||||||
itemScrollController: itemScrollController,
|
|
||||||
itemPositionsListener: itemPositionsListener,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: Dimens.zero,
|
bottom: Dimens.zero,
|
||||||
left: Dimens.zero,
|
left: Dimens.zero,
|
||||||
right: Dimens.zero,
|
right: Dimens.zero,
|
||||||
|
child: Material(
|
||||||
child: ReplyBox(
|
child: ReplyBox(
|
||||||
splitViewEnabled: true,
|
splitViewEnabled: true,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
textEditingController: commentEditingController,
|
textEditingController: commentEditingController,
|
||||||
onSendTapped: onSendTapped,
|
onSendTapped: onSendTapped,
|
||||||
onChanged: context.read<EditCubit>().onTextChanged,
|
onChanged:
|
||||||
|
context.read<EditCubit>().onTextChanged,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -348,20 +342,15 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
||||||
),
|
),
|
||||||
body: MainView(
|
body: MainView(
|
||||||
itemScrollController: itemScrollController,
|
|
||||||
itemPositionsListener: itemPositionsListener,
|
|
||||||
scrollOffsetListener: scrollOffsetListener,
|
scrollOffsetListener: scrollOffsetListener,
|
||||||
commentEditingController: commentEditingController,
|
commentEditingController: commentEditingController,
|
||||||
authState: authState,
|
authState: authState,
|
||||||
topPadding: topPadding,
|
topPadding: context.topPadding,
|
||||||
splitViewEnabled: widget.splitViewEnabled,
|
splitViewEnabled: widget.splitViewEnabled,
|
||||||
onMoreTapped: onMoreTapped,
|
onMoreTapped: onMoreTapped,
|
||||||
onRightMoreTapped: onRightMoreTapped,
|
onRightMoreTapped: onRightMoreTapped,
|
||||||
),
|
),
|
||||||
floatingActionButton: CustomFloatingActionButton(
|
floatingActionButton: const CustomFloatingActionButton(),
|
||||||
itemScrollController: itemScrollController,
|
|
||||||
itemPositionsListener: itemPositionsListener,
|
|
||||||
),
|
|
||||||
bottomSheet: ReplyBox(
|
bottomSheet: ReplyBox(
|
||||||
textEditingController: commentEditingController,
|
textEditingController: commentEditingController,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
@ -437,10 +426,6 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ColoredBox(
|
|
||||||
color: Theme.of(context).canvasColor,
|
|
||||||
child: Material(
|
|
||||||
color: Palette.transparent,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@ -472,8 +457,6 @@ class _ItemScreenState extends State<ItemScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -34,6 +34,7 @@ class CustomAppBar extends AppBar {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
],
|
],
|
||||||
|
const InThreadSearchIconButton(),
|
||||||
IconButton(
|
IconButton(
|
||||||
key: fontSizeIconButtonKey,
|
key: fontSizeIconButtonKey,
|
||||||
icon: Text(
|
icon: Text(
|
||||||
|
@ -3,26 +3,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:hacki/config/constants.dart';
|
import 'package:hacki/config/constants.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
|
import 'package:hacki/extensions/context_extension.dart';
|
||||||
import 'package:hacki/models/discoverable_feature.dart';
|
import 'package:hacki/models/discoverable_feature.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';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
||||||
|
|
||||||
class CustomFloatingActionButton extends StatelessWidget {
|
class CustomFloatingActionButton extends StatelessWidget {
|
||||||
const CustomFloatingActionButton({
|
const CustomFloatingActionButton({
|
||||||
required this.itemScrollController,
|
|
||||||
required this.itemPositionsListener,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ItemScrollController itemScrollController;
|
|
||||||
final ItemPositionsListener itemPositionsListener;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<CommentsCubit, CommentsState>(
|
|
||||||
builder: (BuildContext context, CommentsState state) {
|
|
||||||
return BlocBuilder<EditCubit, EditState>(
|
return BlocBuilder<EditCubit, EditState>(
|
||||||
buildWhen: (EditState previous, EditState current) =>
|
buildWhen: (EditState previous, EditState current) =>
|
||||||
previous.showReplyBox != current.showReplyBox,
|
previous.showReplyBox != current.showReplyBox,
|
||||||
@ -45,23 +38,17 @@ class CustomFloatingActionButton extends StatelessWidget {
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onLongPress: () => itemScrollController.scrollTo(
|
onLongPress: () =>
|
||||||
index: 0,
|
context.read<CommentsCubit>().scrollTo(index: 0),
|
||||||
duration: Durations.ms400,
|
|
||||||
),
|
|
||||||
child: FloatingActionButton.small(
|
child: FloatingActionButton.small(
|
||||||
backgroundColor:
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
|
|
||||||
/// Randomly generated string as heroTag to prevent
|
/// Randomly generated string as heroTag to prevent
|
||||||
/// default [FloatingActionButton] animation.
|
/// default [FloatingActionButton] animation.
|
||||||
heroTag: UniqueKey().hashCode,
|
heroTag: UniqueKey().hashCode,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedbackUtil.selection();
|
HapticFeedbackUtil.selection();
|
||||||
context.read<CommentsCubit>().scrollToPreviousRoot(
|
context.read<CommentsCubit>().scrollToPreviousRoot();
|
||||||
itemScrollController,
|
|
||||||
itemPositionsListener,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.keyboard_arrow_up,
|
Icons.keyboard_arrow_up,
|
||||||
@ -77,21 +64,21 @@ class CustomFloatingActionButton extends StatelessWidget {
|
|||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onLongPress: () => itemScrollController.scrollTo(
|
onLongPress: () {
|
||||||
index: state.comments.length,
|
final CommentsCubit cubit = context.read<CommentsCubit>();
|
||||||
duration: Durations.ms400,
|
cubit.scrollTo(index: cubit.state.comments.length);
|
||||||
),
|
},
|
||||||
child: FloatingActionButton.small(
|
child: FloatingActionButton.small(
|
||||||
backgroundColor:
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
|
|
||||||
/// Same as above.
|
/// Same as above.
|
||||||
heroTag: UniqueKey().hashCode,
|
heroTag: UniqueKey().hashCode,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedbackUtil.selection();
|
HapticFeedbackUtil.selection();
|
||||||
context.read<CommentsCubit>().scrollToNextRoot(
|
context.read<CommentsCubit>().scrollToNextRoot(
|
||||||
itemScrollController,
|
onError: () => context.showSnackBar(
|
||||||
itemPositionsListener,
|
content: '''No more root level comment below.''',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@ -106,7 +93,5 @@ class CustomFloatingActionButton extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
211
lib/screens/item/widgets/in_thread_search_icon_button.dart
Normal file
211
lib/screens/item/widgets/in_thread_search_icon_button.dart
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hacki/config/constants.dart';
|
||||||
import 'package:hacki/models/discoverable_feature.dart';
|
import 'package:hacki/models/discoverable_feature.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';
|
||||||
@ -27,7 +28,7 @@ class LinkIconButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => LinkUtil.launch(
|
onPressed: () => LinkUtil.launch(
|
||||||
'https://news.ycombinator.com/item?id=$storyId',
|
'${Constants.hackerNewsItemLinkPrefix}$storyId',
|
||||||
context,
|
context,
|
||||||
useHackiForHnLink: false,
|
useHackiForHnLink: false,
|
||||||
),
|
),
|
||||||
|
@ -18,8 +18,6 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|||||||
|
|
||||||
class MainView extends StatelessWidget {
|
class MainView extends StatelessWidget {
|
||||||
const MainView({
|
const MainView({
|
||||||
required this.itemScrollController,
|
|
||||||
required this.itemPositionsListener,
|
|
||||||
required this.scrollOffsetListener,
|
required this.scrollOffsetListener,
|
||||||
required this.commentEditingController,
|
required this.commentEditingController,
|
||||||
required this.authState,
|
required this.authState,
|
||||||
@ -30,8 +28,6 @@ class MainView extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ItemScrollController itemScrollController;
|
|
||||||
final ItemPositionsListener itemPositionsListener;
|
|
||||||
final ScrollOffsetListener scrollOffsetListener;
|
final ScrollOffsetListener scrollOffsetListener;
|
||||||
final TextEditingController commentEditingController;
|
final TextEditingController commentEditingController;
|
||||||
final AuthState authState;
|
final AuthState authState;
|
||||||
@ -49,6 +45,9 @@ class MainView extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||||
|
buildWhen: (CommentsState previous, CommentsState current) =>
|
||||||
|
previous.comments.length != current.comments.length ||
|
||||||
|
previous.status != current.status,
|
||||||
builder: (BuildContext context, CommentsState state) {
|
builder: (BuildContext context, CommentsState state) {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
displacement: 100,
|
displacement: 100,
|
||||||
@ -67,8 +66,10 @@ class MainView extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
child: ScrollablePositionedList.builder(
|
child: ScrollablePositionedList.builder(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
itemScrollController: itemScrollController,
|
itemScrollController:
|
||||||
itemPositionsListener: itemPositionsListener,
|
context.read<CommentsCubit>().itemScrollController,
|
||||||
|
itemPositionsListener:
|
||||||
|
context.read<CommentsCubit>().itemPositionsListener,
|
||||||
itemCount: state.comments.length + 2,
|
itemCount: state.comments.length + 2,
|
||||||
padding: EdgeInsets.only(top: topPadding),
|
padding: EdgeInsets.only(top: topPadding),
|
||||||
scrollOffsetListener: scrollOffsetListener,
|
scrollOffsetListener: scrollOffsetListener,
|
||||||
@ -100,13 +101,16 @@ class MainView extends StatelessWidget {
|
|||||||
|
|
||||||
index = index - 1;
|
index = index - 1;
|
||||||
final Comment comment = state.comments.elementAt(index);
|
final Comment comment = state.comments.elementAt(index);
|
||||||
|
|
||||||
return FadeIn(
|
return FadeIn(
|
||||||
key: ValueKey<String>('${comment.id}-FadeIn'),
|
key: ValueKey<String>('${comment.id}-FadeIn'),
|
||||||
child: CommentTile(
|
child: CommentTile(
|
||||||
comment: comment,
|
comment: comment,
|
||||||
|
index: index,
|
||||||
level: comment.level,
|
level: comment.level,
|
||||||
opUsername: state.item.by,
|
opUsername: state.item.by,
|
||||||
fetchMode: state.fetchMode,
|
fetchMode: state.fetchMode,
|
||||||
|
isResponse: state.isResponse(comment),
|
||||||
onReplyTapped: (Comment cmt) {
|
onReplyTapped: (Comment cmt) {
|
||||||
HapticFeedbackUtil.light();
|
HapticFeedbackUtil.light();
|
||||||
if (cmt.deleted || cmt.dead) {
|
if (cmt.deleted || cmt.dead) {
|
||||||
@ -130,7 +134,6 @@ class MainView extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
onMoreTapped: onMoreTapped,
|
onMoreTapped: onMoreTapped,
|
||||||
onRightMoreTapped: onRightMoreTapped,
|
onRightMoreTapped: onRightMoreTapped,
|
||||||
itemScrollController: itemScrollController,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -185,7 +188,7 @@ class _ParentItemSection extends StatelessWidget {
|
|||||||
final ValueChanged<Comment> onRightMoreTapped;
|
final ValueChanged<Comment> onRightMoreTapped;
|
||||||
|
|
||||||
static const double _viewParentButtonWidth = 100;
|
static const double _viewParentButtonWidth = 100;
|
||||||
static const double _viewRootButtonWidth = 80;
|
static const double _viewRootButtonWidth = 85;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -66,11 +66,7 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
builder: (BuildContext context, VoteState voteState) {
|
builder: (BuildContext context, VoteState voteState) {
|
||||||
final bool upvoted = voteState.vote == Vote.up;
|
final bool upvoted = voteState.vote == Vote.up;
|
||||||
final bool downvoted = voteState.vote == Vote.down;
|
final bool downvoted = voteState.vote == Vote.down;
|
||||||
return ColoredBox(
|
return Column(
|
||||||
color: Theme.of(context).canvasColor,
|
|
||||||
child: Material(
|
|
||||||
color: Palette.transparent,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
BlocProvider<UserCubit>(
|
BlocProvider<UserCubit>(
|
||||||
@ -90,6 +86,11 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.pop();
|
context.pop();
|
||||||
|
final double fontSize = context
|
||||||
|
.read<PreferenceCubit>()
|
||||||
|
.state
|
||||||
|
.fontSize
|
||||||
|
.fontSize;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
@ -115,9 +116,12 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
text: HtmlUtil.parseHtml(
|
text: HtmlUtil.parseHtml(
|
||||||
state.user.about,
|
state.user.about,
|
||||||
),
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
),
|
||||||
linkStyle: TextStyle(
|
linkStyle: TextStyle(
|
||||||
color:
|
fontSize: fontSize,
|
||||||
Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
onOpen: (LinkableElement link) =>
|
onOpen: (LinkableElement link) =>
|
||||||
LinkUtil.launch(
|
LinkUtil.launch(
|
||||||
@ -170,8 +174,7 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
? TextStyle(color: Theme.of(context).primaryColor)
|
? TextStyle(color: Theme.of(context).primaryColor)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
subtitle:
|
subtitle: item is Story ? Text(item.score.toString()) : null,
|
||||||
item is Story ? Text(item.score.toString()) : null,
|
|
||||||
onTap: context.read<VoteCubit>().upvote,
|
onTap: context.read<VoteCubit>().upvote,
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@ -233,8 +236,6 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
onTap: () => context.pop(MenuAction.cancel),
|
onTap: () => context.pop(MenuAction.cancel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -245,6 +246,7 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return BlocProvider<SearchCubit>(
|
return BlocProvider<SearchCubit>(
|
||||||
create: (_) => SearchCubit()
|
create: (_) => SearchCubit()
|
||||||
@ -253,22 +255,11 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
author: item.by,
|
author: item.by,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
height: MediaQuery.of(context).size.height - Dimens.pt120,
|
height: MediaQuery.of(context).size.height - Dimens.pt120,
|
||||||
color: Theme.of(context).canvasColor,
|
child: const Column(
|
||||||
margin: const EdgeInsets.only(top: Dimens.pt12),
|
|
||||||
child: Material(
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Container(
|
Expanded(
|
||||||
height: Dimens.pt4,
|
|
||||||
width: Dimens.pt24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Palette.grey,
|
|
||||||
borderRadius: BorderRadius.circular(Dimens.pt16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Expanded(
|
|
||||||
child: SearchScreen(
|
child: SearchScreen(
|
||||||
fromUserDialog: true,
|
fromUserDialog: true,
|
||||||
),
|
),
|
||||||
@ -276,7 +267,6 @@ class MorePopupMenu extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -76,7 +76,8 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
|
|||||||
duration: Durations.ms200,
|
duration: Durations.ms200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
boxShadow: <BoxShadow>[
|
boxShadow: <BoxShadow>[
|
||||||
if (!context.read<SplitViewCubit>().state.enabled)
|
if (!context.read<SplitViewCubit>().state.enabled &&
|
||||||
|
!Theme.of(context).useMaterial3)
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: expanded ? Palette.transparent : Palette.black26,
|
color: expanded ? Palette.transparent : Palette.black26,
|
||||||
blurRadius: Dimens.pt40,
|
blurRadius: Dimens.pt40,
|
||||||
@ -84,6 +85,9 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
|
color: Theme.of(context).useMaterial3
|
||||||
|
? Palette.transparent
|
||||||
|
: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export 'custom_app_bar.dart';
|
export 'custom_app_bar.dart';
|
||||||
export 'custom_floating_action_button.dart';
|
export 'custom_floating_action_button.dart';
|
||||||
export 'fav_icon_button.dart';
|
export 'fav_icon_button.dart';
|
||||||
|
export 'in_thread_search_icon_button.dart';
|
||||||
export 'link_icon_button.dart';
|
export 'link_icon_button.dart';
|
||||||
export 'login_dialog.dart';
|
export 'login_dialog.dart';
|
||||||
export 'main_view.dart';
|
export 'main_view.dart';
|
||||||
|
@ -12,14 +12,13 @@ class QrCodeViewScreen extends StatelessWidget {
|
|||||||
|
|
||||||
static const String routeName = 'qr-code-view';
|
static const String routeName = 'qr-code-view';
|
||||||
|
|
||||||
static const int qrCodeVersion = 4;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Palette.transparent,
|
backgroundColor: Palette.transparent,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@ -35,7 +34,6 @@ class QrCodeViewScreen extends StatelessWidget {
|
|||||||
eyeShape: QrEyeShape.square,
|
eyeShape: QrEyeShape.square,
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
version: qrCodeVersion,
|
|
||||||
size: 300,
|
size: 300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -30,17 +30,29 @@ class SearchScreen extends StatefulWidget {
|
|||||||
class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
||||||
final RefreshController refreshController = RefreshController();
|
final RefreshController refreshController = RefreshController();
|
||||||
final ScrollController scrollController = ScrollController();
|
final ScrollController scrollController = ScrollController();
|
||||||
|
final TextEditingController textEditingController = TextEditingController();
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
|
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
|
||||||
|
|
||||||
static const Duration chipsAnimationDuration = Durations.ms300;
|
static const Duration chipsAnimationDuration = Durations.ms300;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
scrollController.addListener(onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
refreshController.dispose();
|
refreshController.dispose();
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
|
focusNode.dispose();
|
||||||
|
textEditingController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onScroll() => focusNode.unfocus();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||||
@ -53,20 +65,61 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
},
|
},
|
||||||
builder: (BuildContext context, SearchState state) {
|
builder: (BuildContext context, SearchState state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Palette.transparent,
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
body: Column(
|
body: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ColoredBox(
|
Expanded(
|
||||||
color: Theme.of(context).canvasColor,
|
child: SmartRefresher(
|
||||||
child: Column(
|
enablePullDown: false,
|
||||||
|
enablePullUp: true,
|
||||||
|
header: WaterDropMaterialHeader(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
footer: CustomFooter(
|
||||||
|
loadStyle: LoadStyle.ShowWhenLoading,
|
||||||
|
builder: (BuildContext context, LoadStatus? mode) {
|
||||||
|
const double height = 55;
|
||||||
|
late final Widget body;
|
||||||
|
|
||||||
|
if (mode == LoadStatus.loading) {
|
||||||
|
body = const CustomCircularProgressIndicator();
|
||||||
|
} else if (mode == LoadStatus.failed) {
|
||||||
|
body = const Text(
|
||||||
|
'loading failed.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: Center(child: body),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
controller: refreshController,
|
||||||
|
scrollController: scrollController,
|
||||||
|
onRefresh: () {},
|
||||||
|
onLoading: () {
|
||||||
|
context.read<SearchCubit>().loadMore();
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
physics: state.results.isEmpty
|
||||||
|
? const NeverScrollableScrollPhysics()
|
||||||
|
: null,
|
||||||
|
children: <Widget>[
|
||||||
|
Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: Dimens.pt12,
|
horizontal: Dimens.pt12,
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
controller: textEditingController,
|
||||||
|
focusNode: focusNode,
|
||||||
cursorColor: Theme.of(context).primaryColor,
|
cursorColor: Theme.of(context).primaryColor,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@ -109,7 +162,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
startDate: state.dateFilter?.startTime,
|
startDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
endDate: state.dateFilter?.endTime,
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -119,7 +173,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
startDate: state.dateFilter?.startTime,
|
startDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
endDate: state.dateFilter?.endTime,
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -129,7 +184,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
startDate: state.dateFilter?.startTime,
|
startDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
endDate: state.dateFilter?.endTime,
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -139,7 +195,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
startDate: state.dateFilter?.startTime,
|
startDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
endDate: state.dateFilter?.endTime,
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -149,7 +206,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
startDate: state.dateFilter?.startTime,
|
startDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
endDate: state.dateFilter?.endTime,
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -159,7 +217,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
startDate: state.dateFilter?.startTime,
|
startDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
endDate: state.dateFilter?.endTime,
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -177,7 +236,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
),
|
),
|
||||||
DateTimeRangeFilterChip(
|
DateTimeRangeFilterChip(
|
||||||
filter: state.dateFilter,
|
filter: state.dateFilter,
|
||||||
initialStartDate: state.dateFilter?.startTime,
|
initialStartDate:
|
||||||
|
state.dateFilter?.startTime,
|
||||||
initialEndDate: state.dateFilter?.endTime,
|
initialEndDate: state.dateFilter?.endTime,
|
||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
@ -190,7 +250,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
width: Dimens.pt8,
|
width: Dimens.pt8,
|
||||||
),
|
),
|
||||||
PostedByFilterChip(
|
PostedByFilterChip(
|
||||||
filter: state.params.get<PostedByFilter>(),
|
filter:
|
||||||
|
state.params.get<PostedByFilter>(),
|
||||||
onChanged: context
|
onChanged: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onPostedByChanged,
|
.onPostedByChanged,
|
||||||
@ -199,16 +260,28 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
width: Dimens.pt8,
|
width: Dimens.pt8,
|
||||||
),
|
),
|
||||||
CustomChip(
|
CustomChip(
|
||||||
onSelected: (_) =>
|
onSelected: (_) => context
|
||||||
context.read<SearchCubit>().onSortToggled(),
|
.read<SearchCubit>()
|
||||||
|
.onSortToggled(),
|
||||||
selected: state.params.sorted,
|
selected: state.params.sorted,
|
||||||
label: '''newest first''',
|
label: '''newest first''',
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: Dimens.pt8,
|
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
|
for (final CustomDateTimeRange range
|
||||||
in CustomDateTimeRange.values) ...<Widget>[
|
in CustomDateTimeRange
|
||||||
|
.values) ...<Widget>[
|
||||||
CustomRangeFilterChip(
|
CustomRangeFilterChip(
|
||||||
range: range,
|
range: range,
|
||||||
onTap: context
|
onTap: context
|
||||||
@ -247,9 +320,6 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.status == SearchStatus.loading &&
|
if (state.status == SearchStatus.loading &&
|
||||||
state.results.isEmpty) ...<Widget>[
|
state.results.isEmpty) ...<Widget>[
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -273,46 +343,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Expanded(
|
],
|
||||||
child: SmartRefresher(
|
|
||||||
enablePullDown: false,
|
|
||||||
enablePullUp: true,
|
|
||||||
header: WaterDropMaterialHeader(
|
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
|
||||||
),
|
),
|
||||||
footer: CustomFooter(
|
|
||||||
loadStyle: LoadStyle.ShowWhenLoading,
|
|
||||||
builder: (BuildContext context, LoadStatus? mode) {
|
|
||||||
const double height = 55;
|
|
||||||
late final Widget body;
|
|
||||||
|
|
||||||
if (mode == LoadStatus.loading) {
|
|
||||||
body = const CustomCircularProgressIndicator();
|
|
||||||
} else if (mode == LoadStatus.failed) {
|
|
||||||
body = const Text(
|
|
||||||
'loading failed.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
body = const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
height: height,
|
|
||||||
child: Center(child: body),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
controller: refreshController,
|
|
||||||
scrollController: scrollController,
|
|
||||||
onRefresh: () {},
|
|
||||||
onLoading: () {
|
|
||||||
context.read<SearchCubit>().loadMore();
|
|
||||||
},
|
|
||||||
child: ListView(
|
|
||||||
physics: state.results.isEmpty
|
|
||||||
? const NeverScrollableScrollPhysics()
|
|
||||||
: null,
|
|
||||||
children: <Widget>[
|
|
||||||
...state.results
|
...state.results
|
||||||
.map(
|
.map(
|
||||||
(Item e) => <Widget>[
|
(Item e) => <Widget>[
|
||||||
|
@ -49,7 +49,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
|
|||||||
final DateTime? start = filter?.startTime;
|
final DateTime? start = filter?.startTime;
|
||||||
final DateTime? end = filter?.endTime;
|
final DateTime? end = filter?.endTime;
|
||||||
if (start == null && end == null) {
|
if (start == null && end == null) {
|
||||||
return '''from X to Y''';
|
return '''date range''';
|
||||||
} else if (start == end) {
|
} else if (start == end) {
|
||||||
return '''from ${_formatDateTime(start)}''';
|
return '''from ${_formatDateTime(start)}''';
|
||||||
} else {
|
} else {
|
||||||
|
@ -20,6 +20,7 @@ class WebViewScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _WebViewScreenState extends State<WebViewScreen> {
|
class _WebViewScreenState extends State<WebViewScreen> {
|
||||||
final WebViewController controller = WebViewController();
|
final WebViewController controller = WebViewController();
|
||||||
|
bool showFullUrl = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -43,15 +44,26 @@ class _WebViewScreenState extends State<WebViewScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Theme.of(context).canvasColor,
|
backgroundColor: Theme.of(context).canvasColor,
|
||||||
title: Text(
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
humanize(widget.url),
|
title: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
showFullUrl = !showFullUrl;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
showFullUrl
|
||||||
|
? humanize(widget.url)
|
||||||
|
: Uri.parse(widget.url).authority,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: TextDimens.pt12,
|
fontSize: TextDimens.pt14,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
body: WebViewWidget(
|
body: WebViewWidget(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
@ -32,6 +32,12 @@ class CenteredText extends StatelessWidget {
|
|||||||
text: 'blocked',
|
text: 'blocked',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const CenteredText.empty({Key? key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
text: 'empty',
|
||||||
|
);
|
||||||
|
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
|
|||||||
import 'package:hacki/services/services.dart';
|
import 'package:hacki/services/services.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';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
||||||
|
|
||||||
class CommentTile extends StatelessWidget {
|
class CommentTile extends StatelessWidget {
|
||||||
const CommentTile({
|
const CommentTile({
|
||||||
@ -25,19 +24,21 @@ class CommentTile extends StatelessWidget {
|
|||||||
this.actionable = true,
|
this.actionable = true,
|
||||||
this.collapsable = true,
|
this.collapsable = true,
|
||||||
this.selectable = true,
|
this.selectable = true,
|
||||||
|
this.isResponse = false,
|
||||||
this.level = 0,
|
this.level = 0,
|
||||||
|
this.index,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.itemScrollController,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? opUsername;
|
final String? opUsername;
|
||||||
final Comment comment;
|
final Comment comment;
|
||||||
final int level;
|
final int level;
|
||||||
|
final int? index;
|
||||||
final bool actionable;
|
final bool actionable;
|
||||||
final bool collapsable;
|
final bool collapsable;
|
||||||
final bool selectable;
|
final bool selectable;
|
||||||
|
final bool isResponse;
|
||||||
final FetchMode fetchMode;
|
final FetchMode fetchMode;
|
||||||
final ItemScrollController? itemScrollController;
|
|
||||||
|
|
||||||
final void Function(Comment)? onReplyTapped;
|
final void Function(Comment)? onReplyTapped;
|
||||||
final void Function(Comment, Rect?)? onMoreTapped;
|
final void Function(Comment, Rect?)? onMoreTapped;
|
||||||
@ -163,6 +164,24 @@ class CommentTile extends StatelessWidget {
|
|||||||
color: primaryColor,
|
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(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
comment.timeAgo,
|
comment.timeAgo,
|
||||||
@ -351,7 +370,8 @@ class CommentTile extends StatelessWidget {
|
|||||||
final CollapseState collapseState = context.read<CollapseCubit>().state;
|
final CollapseState collapseState = context.read<CollapseCubit>().state;
|
||||||
final CommentsState? commentsState =
|
final CommentsState? commentsState =
|
||||||
context.tryRead<CommentsCubit>()?.state;
|
context.tryRead<CommentsCubit>()?.state;
|
||||||
return fetchMode == FetchMode.lazy &&
|
return actionable &&
|
||||||
|
fetchMode == FetchMode.lazy &&
|
||||||
comment.kids.isNotEmpty &&
|
comment.kids.isNotEmpty &&
|
||||||
collapseState.collapsed == false &&
|
collapseState.collapsed == false &&
|
||||||
commentsState?.commentIds.contains(comment.kids.first) == false &&
|
commentsState?.commentIds.contains(comment.kids.first) == false &&
|
||||||
@ -370,14 +390,14 @@ class CommentTile extends StatelessWidget {
|
|||||||
..collapse(onStateChanged: HapticFeedbackUtil.selection);
|
..collapse(onStateChanged: HapticFeedbackUtil.selection);
|
||||||
if (collapseCubit.state.collapsed &&
|
if (collapseCubit.state.collapsed &&
|
||||||
preferenceCubit.state.autoScrollEnabled) {
|
preferenceCubit.state.autoScrollEnabled) {
|
||||||
final List<Comment> comments =
|
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
|
||||||
context.read<CommentsCubit>().state.comments;
|
final List<Comment> comments = commentsCubit.state.comments;
|
||||||
final int indexOfNextComment = comments.indexOf(comment) + 1;
|
final int indexOfNextComment = comments.indexOf(comment) + 1;
|
||||||
if (indexOfNextComment < comments.length) {
|
if (indexOfNextComment < comments.length) {
|
||||||
Future<void>.delayed(
|
Future<void>.delayed(
|
||||||
Durations.ms300,
|
Durations.ms300,
|
||||||
() {
|
() {
|
||||||
itemScrollController?.scrollTo(
|
commentsCubit.itemScrollController.scrollTo(
|
||||||
index: indexOfNextComment,
|
index: indexOfNextComment,
|
||||||
alignment: 0.1,
|
alignment: 0.1,
|
||||||
duration: Durations.ms300,
|
duration: Durations.ms300,
|
||||||
|
@ -15,11 +15,17 @@ class CustomChip extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bool useMaterial3 = Theme.of(context).useMaterial3;
|
||||||
return FilterChip(
|
return FilterChip(
|
||||||
shadowColor: Palette.transparent,
|
shadowColor: Palette.transparent,
|
||||||
selectedShadowColor: Palette.transparent,
|
selectedShadowColor: Palette.transparent,
|
||||||
backgroundColor: Palette.transparent,
|
backgroundColor: Palette.transparent,
|
||||||
shape: StadiumBorder(
|
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),
|
side: BorderSide(color: Theme.of(context).primaryColor),
|
||||||
),
|
),
|
||||||
label: Text(label),
|
label: Text(label),
|
||||||
|
@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart' show StringCharacters, immutable;
|
|||||||
import 'package:linkify/linkify.dart';
|
import 'package:linkify/linkify.dart';
|
||||||
|
|
||||||
final RegExp _urlRegex = RegExp(
|
final RegExp _urlRegex = RegExp(
|
||||||
r'^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,~-]*)',
|
r'''^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[\/\\\%:\?=&#@;A-Za-z0-9()+_.,'~-]*)''',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
dotAll: true,
|
dotAll: true,
|
||||||
);
|
);
|
||||||
|
@ -25,10 +25,12 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
this.enablePullDown = true,
|
this.enablePullDown = true,
|
||||||
this.markReadStories = false,
|
this.markReadStories = false,
|
||||||
this.showOfflineBanner = false,
|
this.showOfflineBanner = false,
|
||||||
|
this.loadStyle = LoadStyle.ShowWhenLoading,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
this.onLoadMore,
|
this.onLoadMore,
|
||||||
this.onPinned,
|
this.onPinned,
|
||||||
this.header,
|
this.header,
|
||||||
|
this.footer,
|
||||||
this.onMoreTapped,
|
this.onMoreTapped,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
this.itemBuilder,
|
this.itemBuilder,
|
||||||
@ -43,8 +45,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
final bool markReadStories;
|
final bool markReadStories;
|
||||||
final bool showOfflineBanner;
|
final bool showOfflineBanner;
|
||||||
|
|
||||||
|
final LoadStyle loadStyle;
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
final Widget? header;
|
final Widget? header;
|
||||||
|
final Widget? footer;
|
||||||
final RefreshController refreshController;
|
final RefreshController refreshController;
|
||||||
final ScrollController? scrollController;
|
final ScrollController? scrollController;
|
||||||
final VoidCallback? onRefresh;
|
final VoidCallback? onRefresh;
|
||||||
@ -224,6 +228,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
? Column(children: e)
|
? Column(children: e)
|
||||||
: itemBuilder!(Column(children: e), items.elementAt(index)),
|
: itemBuilder!(Column(children: e), items.elementAt(index)),
|
||||||
),
|
),
|
||||||
|
if (footer != null) footer!,
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: Dimens.pt40,
|
height: Dimens.pt40,
|
||||||
),
|
),
|
||||||
@ -237,7 +242,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
backgroundColor: Theme.of(context).primaryColor,
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
footer: CustomFooter(
|
footer: CustomFooter(
|
||||||
loadStyle: LoadStyle.ShowWhenLoading,
|
loadStyle: loadStyle,
|
||||||
builder: (BuildContext context, LoadStatus? mode) {
|
builder: (BuildContext context, LoadStatus? mode) {
|
||||||
const double height = 55;
|
const double height = 55;
|
||||||
late final Widget body;
|
late final Widget body;
|
||||||
|
@ -22,6 +22,7 @@ class OfflineBanner extends StatelessWidget {
|
|||||||
builder: (BuildContext context, StoriesState state) {
|
builder: (BuildContext context, StoriesState state) {
|
||||||
if (state.isOfflineReading) {
|
if (state.isOfflineReading) {
|
||||||
return MaterialBanner(
|
return MaterialBanner(
|
||||||
|
dividerColor: Palette.transparent,
|
||||||
content: Text(
|
content: Text(
|
||||||
'You are currently in offline mode. '
|
'You are currently in offline mode. '
|
||||||
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',
|
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',
|
||||||
|
@ -7,6 +7,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/widgets/widgets.dart';
|
import 'package:hacki/screens/widgets/widgets.dart';
|
||||||
|
import 'package:hacki/styles/styles.dart';
|
||||||
import 'package:hacki/utils/utils.dart';
|
import 'package:hacki/utils/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
import 'package:visibility_detector/visibility_detector.dart';
|
import 'package:visibility_detector/visibility_detector.dart';
|
||||||
@ -48,7 +49,8 @@ class _StoriesListViewState extends State<StoriesListView>
|
|||||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||||
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
|
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
|
||||||
previous.metadataEnabled != current.metadataEnabled,
|
previous.metadataEnabled != current.metadataEnabled ||
|
||||||
|
previous.manualPaginationEnabled != current.manualPaginationEnabled,
|
||||||
builder: (BuildContext context, PreferenceState preferenceState) {
|
builder: (BuildContext context, PreferenceState preferenceState) {
|
||||||
return BlocConsumer<StoriesBloc, StoriesState>(
|
return BlocConsumer<StoriesBloc, StoriesState>(
|
||||||
listenWhen: (StoriesState previous, StoriesState current) =>
|
listenWhen: (StoriesState previous, StoriesState current) =>
|
||||||
@ -70,8 +72,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
|||||||
builder: (BuildContext context, StoriesState state) {
|
builder: (BuildContext context, StoriesState state) {
|
||||||
return ItemsListView<Story>(
|
return ItemsListView<Story>(
|
||||||
showOfflineBanner: true,
|
showOfflineBanner: true,
|
||||||
markReadStories:
|
markReadStories: preferenceState.markReadStoriesEnabled,
|
||||||
context.read<PreferenceCubit>().state.markReadStoriesEnabled,
|
|
||||||
showWebPreviewOnStoryTile:
|
showWebPreviewOnStoryTile:
|
||||||
preferenceState.complexStoryTileEnabled,
|
preferenceState.complexStoryTileEnabled,
|
||||||
showMetadataOnStoryTile: preferenceState.metadataEnabled,
|
showMetadataOnStoryTile: preferenceState.metadataEnabled,
|
||||||
@ -87,13 +88,42 @@ class _StoriesListViewState extends State<StoriesListView>
|
|||||||
context.read<PinCubit>().refresh();
|
context.read<PinCubit>().refresh();
|
||||||
},
|
},
|
||||||
onLoadMore: () {
|
onLoadMore: () {
|
||||||
context
|
if (preferenceState.manualPaginationEnabled) {
|
||||||
.read<StoriesBloc>()
|
refreshController
|
||||||
.add(StoriesLoadMore(type: storyType));
|
..refreshCompleted(resetFooterState: true)
|
||||||
|
..loadComplete();
|
||||||
|
} else {
|
||||||
|
loadMoreStories();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTap: onStoryTapped,
|
onTap: onStoryTapped,
|
||||||
onPinned: context.read<PinCubit>().pinStory,
|
onPinned: context.read<PinCubit>().pinStory,
|
||||||
header: state.isOfflineReading ? null : header,
|
header: state.isOfflineReading ? null : header,
|
||||||
|
loadStyle: LoadStyle.HideAlways,
|
||||||
|
footer: preferenceState.manualPaginationEnabled &&
|
||||||
|
state.statusByType[widget.storyType] == Status.success &&
|
||||||
|
(state.storiesByType[widget.storyType]?.length ?? 0) <
|
||||||
|
(state.storyIdsByType[widget.storyType]?.length ?? 0)
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: Dimens.pt48,
|
||||||
|
right: Dimens.pt48,
|
||||||
|
top: Dimens.pt36,
|
||||||
|
bottom: Dimens.pt12,
|
||||||
|
),
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: loadMoreStories,
|
||||||
|
style: ButtonStyle(
|
||||||
|
foregroundColor: MaterialStateColor.resolveWith(
|
||||||
|
(_) => Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'''Load Page ${(state.currentPageByType[widget.storyType] ?? 0) + 2}''',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onMoreTapped: onMoreTapped,
|
onMoreTapped: onMoreTapped,
|
||||||
itemBuilder: (Widget child, Story story) {
|
itemBuilder: (Widget child, Story story) {
|
||||||
return Slidable(
|
return Slidable(
|
||||||
@ -162,4 +192,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void loadMoreStories() =>
|
||||||
|
context.read<StoriesBloc>().add(StoriesLoadMore(type: widget.storyType));
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,7 @@ class _TapDownWrapperState extends State<TapDownWrapper>
|
|||||||
onTapDown: onTapDown,
|
onTapDown: onTapDown,
|
||||||
onTapUp: onTapUp,
|
onTapUp: onTapUp,
|
||||||
onTapCancel: onTapCancel,
|
onTapCancel: onTapCancel,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation:
|
animation:
|
||||||
CurvedAnimation(parent: controller, curve: Curves.decelerate),
|
CurvedAnimation(parent: controller, curve: Curves.decelerate),
|
||||||
|
@ -1,7 +1,17 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
abstract class HapticFeedbackUtil {
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.13.0"
|
version: "5.13.0"
|
||||||
|
animations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: animations
|
||||||
|
sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
name: hacki
|
name: hacki
|
||||||
description: A Hacker News reader.
|
description: A Hacker News reader.
|
||||||
version: 2.0.1+126
|
version: 2.3.0+129
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@ -9,6 +9,7 @@ environment:
|
|||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
adaptive_theme: ^3.2.0
|
adaptive_theme: ^3.2.0
|
||||||
|
animations: ^2.0.8
|
||||||
badges: ^3.0.2
|
badges: ^3.0.2
|
||||||
bloc: ^8.1.1
|
bloc: ^8.1.1
|
||||||
cached_network_image: ^3.2.3
|
cached_network_image: ^3.2.3
|
||||||
|
Reference in New Issue
Block a user