mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
1f4e6cf41c | |||
be6ed35888 | |||
b2ea50cea6 | |||
109b9287cf | |||
939d55ef0d | |||
3ee60e1a44 | |||
6fe567fa02 | |||
bc2d4f32c9 | |||
91290e9743 | |||
934f184b6f | |||
dbd48eae99 | |||
279007191b | |||
b3fdc20fc5 | |||
3fbf5d4eea | |||
332ffbb773 | |||
346a6c709e |
@ -1,7 +1,7 @@
|
||||
|
||||
# <img width="64" src="https://user-images.githubusercontent.com/7277662/167775086-0b234f28-dee4-44f6-aae4-14a28ed4bbb6.png"> Hacki for Hacker News
|
||||
|
||||
A [Hacker News](https://news.ycombinator.com/) client made with Flutter that is just enough.
|
||||
A [Hacker News](https://news.ycombinator.com/) client built with Flutter.
|
||||
|
||||
[](https://apps.apple.com/us/app/hacki/id1602043763?platform=iphone)
|
||||
[](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.
|
@ -20,6 +20,8 @@ abstract class Constants {
|
||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
||||
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
|
||||
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
|
||||
static const String hackerNewsItemLinkPrefix =
|
||||
'https://news.ycombinator.com/item?id=';
|
||||
static const String supportEmail = 'georgefung98@gmail.com';
|
||||
|
||||
static const String _imagePath = 'assets/images';
|
||||
|
@ -9,6 +9,7 @@ import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
@ -61,6 +62,10 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
final SembastRepository _sembastRepository;
|
||||
final Logger _logger;
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
/// The [StreamSubscription] for stream (both lazy or eager)
|
||||
/// fetching comments posted directly to the story.
|
||||
StreamSubscription<Comment>? _streamSubscription;
|
||||
@ -108,6 +113,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.copyWith(
|
||||
status: CommentsStatus.inProgress,
|
||||
comments: <Comment>[],
|
||||
matchedComments: <int>[],
|
||||
inThreadSearchQuery: '',
|
||||
currentPage: 0,
|
||||
),
|
||||
);
|
||||
@ -213,6 +220,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
state.copyWith(
|
||||
onlyShowTargetComment: false,
|
||||
item: story,
|
||||
matchedComments: <int>[],
|
||||
),
|
||||
);
|
||||
init();
|
||||
@ -349,17 +357,26 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
init(useCommentCache: true);
|
||||
}
|
||||
|
||||
void scrollTo({
|
||||
required int index,
|
||||
double alignment = 0.0,
|
||||
}) {
|
||||
debugPrint('Scrolling to: $index, alignment: $alignment');
|
||||
itemScrollController.scrollTo(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: Durations.ms400,
|
||||
);
|
||||
}
|
||||
|
||||
/// Scroll to next root level comment.
|
||||
void scrollToNextRoot(
|
||||
ItemScrollController itemScrollController,
|
||||
ItemPositionsListener itemPositionsListener,
|
||||
) {
|
||||
void scrollToNextRoot() {
|
||||
final int totalComments = state.comments.length;
|
||||
final List<Comment> onScreenComments = itemPositionsListener
|
||||
.itemPositions.value
|
||||
// The header is also a part of the list view,
|
||||
// thus ignoring it here.
|
||||
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge < 0.7)
|
||||
.where((ItemPosition e) => e.index >= 1 && e.itemLeadingEdge > 0.1)
|
||||
.sorted((ItemPosition a, ItemPosition b) => a.index.compareTo(b.index))
|
||||
.map(
|
||||
(ItemPosition e) => e.index <= state.comments.length
|
||||
@ -369,9 +386,29 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
|
||||
/// The index of last comment visible on screen.
|
||||
final int lastVisibleIndex = state.comments.indexOf(onScreenComments.last);
|
||||
final int startIndex = min(lastVisibleIndex + 1, totalComments);
|
||||
if (onScreenComments.isEmpty && state.comments.isNotEmpty) {
|
||||
itemScrollController.scrollTo(
|
||||
index: 1,
|
||||
alignment: 0.15,
|
||||
duration: Durations.ms400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final Comment? firstVisibleRootComment =
|
||||
onScreenComments.firstWhereOrNull((Comment e) => e.isRoot);
|
||||
late int startIndex;
|
||||
|
||||
if (firstVisibleRootComment != null) {
|
||||
/// The index of first root level comment visible on screen.
|
||||
final int firstVisibleRootCommentIndex =
|
||||
state.comments.indexOf(firstVisibleRootComment);
|
||||
startIndex = min(firstVisibleRootCommentIndex + 1, totalComments);
|
||||
} else {
|
||||
final int lastVisibleCommentIndex =
|
||||
state.comments.indexOf(onScreenComments.last);
|
||||
startIndex = min(lastVisibleCommentIndex + 1, totalComments);
|
||||
}
|
||||
|
||||
for (int i = startIndex; i < totalComments; i++) {
|
||||
final Comment cmt = state.comments.elementAt(i);
|
||||
@ -388,10 +425,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
|
||||
/// Scroll to previous root level comment.
|
||||
void scrollToPreviousRoot(
|
||||
ItemScrollController itemScrollController,
|
||||
ItemPositionsListener itemPositionsListener,
|
||||
) {
|
||||
void scrollToPreviousRoot() {
|
||||
final List<Comment> onScreenComments = itemPositionsListener
|
||||
.itemPositions.value
|
||||
// The header is also a part of the list view,
|
||||
@ -426,6 +460,28 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
}
|
||||
}
|
||||
|
||||
void search(String query) {
|
||||
resetSearch();
|
||||
|
||||
if (query.isEmpty) return;
|
||||
|
||||
final String lowercaseQuery = query.toLowerCase();
|
||||
for (final int i in 0.to(state.comments.length, inclusive: false)) {
|
||||
final Comment cmt = state.comments.elementAt(i);
|
||||
if (cmt.text.toLowerCase().contains(lowercaseQuery)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
matchedComments: <int>[...state.matchedComments, i],
|
||||
inThreadSearchQuery: query,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void resetSearch() =>
|
||||
emit(state.copyWith(matchedComments: <int>[], inThreadSearchQuery: ''));
|
||||
|
||||
List<int> _sortKids(List<int> kids) {
|
||||
switch (state.order) {
|
||||
case CommentsOrder.natural:
|
||||
|
@ -12,6 +12,7 @@ class CommentsState extends Equatable {
|
||||
const CommentsState({
|
||||
required this.item,
|
||||
required this.comments,
|
||||
required this.matchedComments,
|
||||
required this.status,
|
||||
required this.fetchParentStatus,
|
||||
required this.fetchRootStatus,
|
||||
@ -20,6 +21,7 @@ class CommentsState extends Equatable {
|
||||
required this.onlyShowTargetComment,
|
||||
required this.isOfflineReading,
|
||||
required this.currentPage,
|
||||
required this.inThreadSearchQuery,
|
||||
});
|
||||
|
||||
CommentsState.init({
|
||||
@ -28,11 +30,13 @@ class CommentsState extends Equatable {
|
||||
required this.fetchMode,
|
||||
required this.order,
|
||||
}) : comments = <Comment>[],
|
||||
matchedComments = <int>[],
|
||||
status = CommentsStatus.idle,
|
||||
fetchParentStatus = CommentsStatus.idle,
|
||||
fetchRootStatus = CommentsStatus.idle,
|
||||
onlyShowTargetComment = false,
|
||||
currentPage = 0;
|
||||
currentPage = 0,
|
||||
inThreadSearchQuery = '';
|
||||
|
||||
final Item item;
|
||||
final List<Comment> comments;
|
||||
@ -44,10 +48,15 @@ class CommentsState extends Equatable {
|
||||
final bool onlyShowTargetComment;
|
||||
final bool isOfflineReading;
|
||||
final int currentPage;
|
||||
final String inThreadSearchQuery;
|
||||
|
||||
/// Indexes of comments that matches the query for in-thread search.
|
||||
final List<int> matchedComments;
|
||||
|
||||
CommentsState copyWith({
|
||||
Item? item,
|
||||
List<Comment>? comments,
|
||||
List<int>? matchedComments,
|
||||
CommentsStatus? status,
|
||||
CommentsStatus? fetchParentStatus,
|
||||
CommentsStatus? fetchRootStatus,
|
||||
@ -56,10 +65,12 @@ class CommentsState extends Equatable {
|
||||
bool? onlyShowTargetComment,
|
||||
bool? isOfflineReading,
|
||||
int? currentPage,
|
||||
String? inThreadSearchQuery,
|
||||
}) {
|
||||
return CommentsState(
|
||||
item: item ?? this.item,
|
||||
comments: comments ?? this.comments,
|
||||
matchedComments: matchedComments ?? this.matchedComments,
|
||||
fetchParentStatus: fetchParentStatus ?? this.fetchParentStatus,
|
||||
fetchRootStatus: fetchRootStatus ?? this.fetchRootStatus,
|
||||
status: status ?? this.status,
|
||||
@ -69,6 +80,7 @@ class CommentsState extends Equatable {
|
||||
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
inThreadSearchQuery: inThreadSearchQuery ?? this.inThreadSearchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
@ -86,5 +98,7 @@ class CommentsState extends Equatable {
|
||||
isOfflineReading,
|
||||
currentPage,
|
||||
comments,
|
||||
matchedComments,
|
||||
inThreadSearchQuery,
|
||||
];
|
||||
}
|
||||
|
@ -70,6 +70,10 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get customTabEnabled => _isOn<CustomTabPreference>();
|
||||
|
||||
bool get material3Enabled => _isOn<Material3Preference>();
|
||||
|
||||
bool get paginationEnabled => _isOn<PaginationPreference>();
|
||||
|
||||
double get textScaleFactor =>
|
||||
preferences.singleWhereType<TextScaleFactorPreference>().val;
|
||||
|
||||
|
@ -85,4 +85,8 @@ extension ContextExtension on BuildContext {
|
||||
int get 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:go_router/go_router.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
@ -103,31 +104,26 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => context.pop(item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => context.pop(
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => context.pop(item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => context.pop(
|
||||
'${Constants.hackerNewsItemLinkPrefix}${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
|
||||
linkToShare = '${Constants.hackerNewsItemLinkPrefix}${item.id}';
|
||||
}
|
||||
|
||||
if (linkToShare != null) {
|
||||
|
@ -233,10 +233,13 @@ class HackiApp extends StatelessWidget {
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.appColor != current.appColor ||
|
||||
previous.font != current.font ||
|
||||
previous.textScaleFactor != current.textScaleFactor,
|
||||
previous.textScaleFactor != current.textScaleFactor ||
|
||||
previous.material3Enabled != current.material3Enabled,
|
||||
builder: (BuildContext context, PreferenceState state) {
|
||||
return AdaptiveTheme(
|
||||
key: ValueKey<String>('${state.appColor}${state.font}'),
|
||||
key: ValueKey<String>(
|
||||
'''${state.appColor}${state.font}${state.material3Enabled}''',
|
||||
),
|
||||
light: ThemeData(
|
||||
primaryColor: state.appColor,
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
@ -287,7 +290,61 @@ class HackiApp extends StatelessWidget {
|
||||
title: 'Hacki',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
|
||||
useMaterial3: false,
|
||||
useMaterial3: state.material3Enabled,
|
||||
dividerTheme: state.material3Enabled
|
||||
? DividerThemeData(
|
||||
color: Palette.grey.withOpacity(0.2),
|
||||
)
|
||||
: null,
|
||||
switchTheme: state.material3Enabled
|
||||
? SwitchThemeData(
|
||||
trackColor: MaterialStateProperty.resolveWith(
|
||||
(Set<MaterialState> states) {
|
||||
if (states
|
||||
.contains(MaterialState.selected)) {
|
||||
return null;
|
||||
} else {
|
||||
return Palette.grey.withOpacity(0.2);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
bottomSheetTheme: state.material3Enabled
|
||||
? const BottomSheetThemeData(
|
||||
modalElevation: 8,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shadowColor: Palette.black,
|
||||
)
|
||||
: null,
|
||||
inputDecorationTheme: state.material3Enabled
|
||||
? InputDecorationTheme(
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: isDarkModeEnabled
|
||||
? Palette.white
|
||||
: Palette.black,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
sliderTheme: state.material3Enabled
|
||||
? SliderThemeData(
|
||||
inactiveTrackColor:
|
||||
state.appColor.shade200.withOpacity(0.5),
|
||||
)
|
||||
: null,
|
||||
outlinedButtonTheme: state.material3Enabled
|
||||
? OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
side: MaterialStateBorderSide.resolveWith(
|
||||
(_) => const BorderSide(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
routerConfig: router,
|
||||
),
|
||||
|
@ -25,13 +25,18 @@ enum DiscoverableFeature {
|
||||
featureId: 'jump_up_button_with_long_press',
|
||||
title: 'Shortcut',
|
||||
description:
|
||||
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
|
||||
'''Tapping on this button will take you to the previous root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
|
||||
),
|
||||
jumpDownButton(
|
||||
featureId: 'jump_down_button_with_long_press',
|
||||
title: 'Shortcut',
|
||||
description:
|
||||
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''',
|
||||
'''Tapping on this button will take you to the next root level comment.\n\nLong press on it to jump to the end of this thread.''',
|
||||
),
|
||||
searchInThread(
|
||||
featureId: 'search_in_thread',
|
||||
title: 'Search in Thread',
|
||||
description: '''Search for comments in this thread.''',
|
||||
);
|
||||
|
||||
const DiscoverableFeature({
|
||||
|
@ -37,12 +37,14 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const MarkReadStoriesModePreference(),
|
||||
// Divider.
|
||||
const NotificationModePreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const AutoScrollModePreference(),
|
||||
const CollapseModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const CustomTabPreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const Material3Preference(),
|
||||
const PaginationPreference(),
|
||||
const SwipeGesturePreference(),
|
||||
],
|
||||
);
|
||||
|
||||
@ -73,6 +75,8 @@ const bool _storyUrlModeDefaultValue = true;
|
||||
const bool _collapseModeDefaultValue = true;
|
||||
const bool _autoScrollModeDefaultValue = false;
|
||||
const bool _customTabModeDefaultValue = false;
|
||||
const bool _material3ModeDefaultValue = false;
|
||||
const bool _paginationModeDefaultValue = false;
|
||||
const double _textScaleFactorDefaultValue = 1;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
@ -285,6 +289,45 @@ class EyeCandyModePreference extends BooleanPreference {
|
||||
String get subtitle => 'some sort of magic.';
|
||||
}
|
||||
|
||||
class PaginationPreference extends BooleanPreference {
|
||||
const PaginationPreference({bool? val})
|
||||
: super(val: val ?? _paginationModeDefaultValue);
|
||||
|
||||
@override
|
||||
PaginationPreference copyWith({required bool? val}) {
|
||||
return PaginationPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'paginationMode';
|
||||
|
||||
@override
|
||||
String get title => 'Enable 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 => 'Enable Material 3';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''experimental feature. Please open an issue on GitHub if you notice anything weird.''';
|
||||
}
|
||||
|
||||
/// Whether or not to use Custom Tabs for launching URLs.
|
||||
/// If false, default browser will be used.
|
||||
///
|
||||
|
@ -49,7 +49,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.didPopNext();
|
||||
if (context.read<StoriesBloc>().deviceScreenType ==
|
||||
DeviceScreenType.mobile) {
|
||||
locator.get<Logger>().i('resetting comments in CommentCache');
|
||||
locator.get<Logger>().i('Resetting comments in CommentCache');
|
||||
Future<void>.delayed(
|
||||
Durations.ms500,
|
||||
locator.get<CommentCache>().resetComments,
|
||||
|
@ -142,9 +142,6 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
final TextEditingController commentEditingController =
|
||||
TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
final ScrollOffsetListener scrollOffsetListener =
|
||||
ScrollOffsetListener.create();
|
||||
final Throttle storyLinkTapThrottle = Throttle(
|
||||
@ -182,6 +179,7 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
DiscoverableFeature.searchInThread.featureId,
|
||||
DiscoverableFeature.pinToTop.featureId,
|
||||
DiscoverableFeature.addStoryToFavList.featureId,
|
||||
DiscoverableFeature.openStoryInWebView.featureId,
|
||||
@ -218,8 +216,6 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double topPadding =
|
||||
MediaQuery.of(context).padding.top + kToolbarHeight;
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (BuildContext context, AuthState authState) {
|
||||
return MultiBlocListener(
|
||||
@ -272,12 +268,10 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
children: <Widget>[
|
||||
Positioned.fill(
|
||||
child: MainView(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
commentEditingController: commentEditingController,
|
||||
authState: authState,
|
||||
topPadding: topPadding,
|
||||
topPadding: context.topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
@ -313,24 +307,24 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
const Positioned(
|
||||
right: Dimens.pt12,
|
||||
bottom: Dimens.pt36,
|
||||
child: CustomFloatingActionButton(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
),
|
||||
child: CustomFloatingActionButton(),
|
||||
),
|
||||
Positioned(
|
||||
bottom: Dimens.zero,
|
||||
left: Dimens.zero,
|
||||
right: Dimens.zero,
|
||||
child: ReplyBox(
|
||||
splitViewEnabled: true,
|
||||
focusNode: focusNode,
|
||||
textEditingController: commentEditingController,
|
||||
onSendTapped: onSendTapped,
|
||||
onChanged: context.read<EditCubit>().onTextChanged,
|
||||
child: Material(
|
||||
child: ReplyBox(
|
||||
splitViewEnabled: true,
|
||||
focusNode: focusNode,
|
||||
textEditingController: commentEditingController,
|
||||
onSendTapped: onSendTapped,
|
||||
onChanged:
|
||||
context.read<EditCubit>().onTextChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -348,20 +342,15 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
||||
),
|
||||
body: MainView(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
commentEditingController: commentEditingController,
|
||||
authState: authState,
|
||||
topPadding: topPadding,
|
||||
topPadding: context.topPadding,
|
||||
splitViewEnabled: widget.splitViewEnabled,
|
||||
onMoreTapped: onMoreTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
),
|
||||
floatingActionButton: CustomFloatingActionButton(
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
),
|
||||
floatingActionButton: const CustomFloatingActionButton(),
|
||||
bottomSheet: ReplyBox(
|
||||
textEditingController: commentEditingController,
|
||||
focusNode: focusNode,
|
||||
@ -437,42 +426,36 @@ class _ItemScreenState extends State<ItemScreen>
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
onTimeMachineActivated(comment);
|
||||
},
|
||||
enabled:
|
||||
comment.level > 0 && !(comment.dead || comment.deleted),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list),
|
||||
title: const Text('View in separate thread'),
|
||||
onTap: () {
|
||||
locator.get<AppReviewService>().requestReview();
|
||||
context.pop();
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: comment,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
enabled: !(comment.dead || comment.deleted),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
onTimeMachineActivated(comment);
|
||||
},
|
||||
enabled:
|
||||
comment.level > 0 && !(comment.dead || comment.deleted),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list),
|
||||
title: const Text('View in separate thread'),
|
||||
onTap: () {
|
||||
locator.get<AppReviewService>().requestReview();
|
||||
context.pop();
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: comment,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
enabled: !(comment.dead || comment.deleted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -34,6 +34,7 @@ class CustomAppBar extends AppBar {
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
const InThreadSearchIconButton(),
|
||||
IconButton(
|
||||
key: fontSizeIconButtonKey,
|
||||
icon: Text(
|
||||
|
@ -7,104 +7,84 @@ import 'package:hacki/models/discoverable_feature.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class CustomFloatingActionButton extends StatelessWidget {
|
||||
const CustomFloatingActionButton({
|
||||
required this.itemScrollController,
|
||||
required this.itemPositionsListener,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ItemScrollController itemScrollController;
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CommentsCubit, CommentsState>(
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return BlocBuilder<EditCubit, EditState>(
|
||||
buildWhen: (EditState previous, EditState current) =>
|
||||
previous.showReplyBox != current.showReplyBox,
|
||||
builder: (BuildContext context, EditState editState) {
|
||||
return AnimatedPadding(
|
||||
padding: editState.showReplyBox
|
||||
? const EdgeInsets.only(
|
||||
bottom: Dimens.replyBoxCollapsedHeight,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
duration: Durations.ms200,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpUpButton,
|
||||
contentLocation: ContentLocation.above,
|
||||
tapTarget: const Icon(
|
||||
return BlocBuilder<EditCubit, EditState>(
|
||||
buildWhen: (EditState previous, EditState current) =>
|
||||
previous.showReplyBox != current.showReplyBox,
|
||||
builder: (BuildContext context, EditState editState) {
|
||||
return AnimatedPadding(
|
||||
padding: editState.showReplyBox
|
||||
? const EdgeInsets.only(
|
||||
bottom: Dimens.replyBoxCollapsedHeight,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
duration: Durations.ms200,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpUpButton,
|
||||
contentLocation: ContentLocation.above,
|
||||
tapTarget: const Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () =>
|
||||
context.read<CommentsCubit>().scrollTo(index: 0),
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Randomly generated string as heroTag to prevent
|
||||
/// default [FloatingActionButton] animation.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToPreviousRoot();
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () => itemScrollController.scrollTo(
|
||||
index: 0,
|
||||
duration: Durations.ms400,
|
||||
),
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor:
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Randomly generated string as heroTag to prevent
|
||||
/// default [FloatingActionButton] animation.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToPreviousRoot(
|
||||
itemScrollController,
|
||||
itemPositionsListener,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpDownButton,
|
||||
tapTarget: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () => itemScrollController.scrollTo(
|
||||
index: state.comments.length,
|
||||
duration: Durations.ms400,
|
||||
),
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor:
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Same as above.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToNextRoot(
|
||||
itemScrollController,
|
||||
itemPositionsListener,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
CustomDescribedFeatureOverlay(
|
||||
feature: DiscoverableFeature.jumpDownButton,
|
||||
tapTarget: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Palette.white,
|
||||
),
|
||||
child: InkWell(
|
||||
onLongPress: () {
|
||||
final CommentsCubit cubit = context.read<CommentsCubit>();
|
||||
cubit.scrollTo(index: cubit.state.comments.length);
|
||||
},
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
|
||||
/// Same as above.
|
||||
heroTag: UniqueKey().hashCode,
|
||||
onPressed: () {
|
||||
HapticFeedbackUtil.selection();
|
||||
context.read<CommentsCubit>().scrollToNextRoot();
|
||||
},
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
161
lib/screens/item/widgets/in_thread_search_icon_button.dart
Normal file
161
lib/screens/item/widgets/in_thread_search_icon_button.dart
Normal file
@ -0,0 +1,161 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_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,
|
||||
builder: (BuildContext context, CommentsState 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: context.read<CommentsCubit>().search,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: widget.action,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
controller: scrollController,
|
||||
shrinkWrap: true,
|
||||
children: <Widget>[
|
||||
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:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/discoverable_feature.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -27,7 +28,7 @@ class LinkIconButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onPressed: () => LinkUtil.launch(
|
||||
'https://news.ycombinator.com/item?id=$storyId',
|
||||
'${Constants.hackerNewsItemLinkPrefix}$storyId',
|
||||
context,
|
||||
useHackiForHnLink: false,
|
||||
),
|
||||
|
@ -18,8 +18,6 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class MainView extends StatelessWidget {
|
||||
const MainView({
|
||||
required this.itemScrollController,
|
||||
required this.itemPositionsListener,
|
||||
required this.scrollOffsetListener,
|
||||
required this.commentEditingController,
|
||||
required this.authState,
|
||||
@ -30,8 +28,6 @@ class MainView extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ItemScrollController itemScrollController;
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
final ScrollOffsetListener scrollOffsetListener;
|
||||
final TextEditingController commentEditingController;
|
||||
final AuthState authState;
|
||||
@ -49,6 +45,9 @@ class MainView extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Positioned.fill(
|
||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||
buildWhen: (CommentsState previous, CommentsState current) =>
|
||||
previous.comments.length != current.comments.length ||
|
||||
previous.status != current.status,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return RefreshIndicator(
|
||||
displacement: 100,
|
||||
@ -67,8 +66,10 @@ class MainView extends StatelessWidget {
|
||||
},
|
||||
child: ScrollablePositionedList.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
itemScrollController:
|
||||
context.read<CommentsCubit>().itemScrollController,
|
||||
itemPositionsListener:
|
||||
context.read<CommentsCubit>().itemPositionsListener,
|
||||
itemCount: state.comments.length + 2,
|
||||
padding: EdgeInsets.only(top: topPadding),
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
@ -104,6 +105,7 @@ class MainView extends StatelessWidget {
|
||||
key: ValueKey<String>('${comment.id}-FadeIn'),
|
||||
child: CommentTile(
|
||||
comment: comment,
|
||||
index: index,
|
||||
level: comment.level,
|
||||
opUsername: state.item.by,
|
||||
fetchMode: state.fetchMode,
|
||||
@ -130,7 +132,6 @@ class MainView extends StatelessWidget {
|
||||
},
|
||||
onMoreTapped: onMoreTapped,
|
||||
onRightMoreTapped: onRightMoreTapped,
|
||||
itemScrollController: itemScrollController,
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -185,7 +186,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
|
||||
static const double _viewParentButtonWidth = 100;
|
||||
static const double _viewRootButtonWidth = 80;
|
||||
static const double _viewRootButtonWidth = 85;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -66,175 +66,176 @@ class MorePopupMenu extends StatelessWidget {
|
||||
builder: (BuildContext context, VoteState voteState) {
|
||||
final bool upvoted = voteState.vote == Vote.up;
|
||||
final bool downvoted = voteState.vote == Vote.down;
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
BlocProvider<UserCubit>(
|
||||
create: (BuildContext context) =>
|
||||
UserCubit()..init(userId: item.by),
|
||||
child: BlocBuilder<UserCubit, UserState>(
|
||||
builder: (BuildContext context, UserState state) {
|
||||
return Semantics(
|
||||
excludeSemantics: state.status == Status.inProgress,
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.account_circle,
|
||||
),
|
||||
title: Text(item.by),
|
||||
subtitle: Text(
|
||||
state.user.description,
|
||||
),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
semanticLabel:
|
||||
'''About ${state.user.id}. ${state.user.about}''',
|
||||
title: Text(
|
||||
'About ${state.user.id}',
|
||||
),
|
||||
content: state.user.about.isEmpty
|
||||
? const Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'empty',
|
||||
style: TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SelectableLinkify(
|
||||
text: HtmlUtil.parseHtml(
|
||||
state.user.about,
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
BlocProvider<UserCubit>(
|
||||
create: (BuildContext context) =>
|
||||
UserCubit()..init(userId: item.by),
|
||||
child: BlocBuilder<UserCubit, UserState>(
|
||||
builder: (BuildContext context, UserState state) {
|
||||
return Semantics(
|
||||
excludeSemantics: state.status == Status.inProgress,
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.account_circle,
|
||||
),
|
||||
title: Text(item.by),
|
||||
subtitle: Text(
|
||||
state.user.description,
|
||||
),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
final double fontSize = context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.fontSize
|
||||
.fontSize;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
semanticLabel:
|
||||
'''About ${state.user.id}. ${state.user.about}''',
|
||||
title: Text(
|
||||
'About ${state.user.id}',
|
||||
),
|
||||
content: state.user.about.isEmpty
|
||||
? const Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'empty',
|
||||
style: TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color:
|
||||
Theme.of(context).primaryColor,
|
||||
),
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch(
|
||||
link.url,
|
||||
context,
|
||||
),
|
||||
semanticsLabel: state.user.about,
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
locator
|
||||
.get<AppReviewService>()
|
||||
.requestReview();
|
||||
context.pop();
|
||||
onSearchUserTapped(context);
|
||||
},
|
||||
child: const Text(
|
||||
'Search',
|
||||
],
|
||||
)
|
||||
: SelectableLinkify(
|
||||
text: HtmlUtil.parseHtml(
|
||||
state.user.about,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
locator
|
||||
.get<AppReviewService>()
|
||||
.requestReview();
|
||||
context.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Okay',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
onOpen: (LinkableElement link) =>
|
||||
LinkUtil.launch(
|
||||
link.url,
|
||||
context,
|
||||
),
|
||||
semanticsLabel: state.user.about,
|
||||
),
|
||||
],
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
locator
|
||||
.get<AppReviewService>()
|
||||
.requestReview();
|
||||
context.pop();
|
||||
onSearchUserTapped(context);
|
||||
},
|
||||
child: const Text(
|
||||
'Search',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
FeatherIcons.chevronUp,
|
||||
color: upvoted ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
title: Text(
|
||||
upvoted ? 'Upvoted' : 'Upvote',
|
||||
style: upvoted
|
||||
? TextStyle(color: Theme.of(context).primaryColor)
|
||||
: null,
|
||||
),
|
||||
subtitle:
|
||||
item is Story ? Text(item.score.toString()) : null,
|
||||
onTap: context.read<VoteCubit>().upvote,
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
FeatherIcons.chevronDown,
|
||||
color: downvoted ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
title: Text(
|
||||
downvoted ? 'Downvoted' : 'Downvote',
|
||||
style: downvoted
|
||||
? TextStyle(color: Theme.of(context).primaryColor)
|
||||
: null,
|
||||
),
|
||||
onTap: context.read<VoteCubit>().downvote,
|
||||
),
|
||||
BlocBuilder<FavCubit, FavState>(
|
||||
builder: (BuildContext context, FavState state) {
|
||||
final bool isFav = state.favIds.contains(item.id);
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
title: Text(
|
||||
isFav ? 'Unfavorite' : 'Favorite',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.fav),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(FeatherIcons.share),
|
||||
title: const Text(
|
||||
'Share',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.share),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.local_police),
|
||||
title: const Text(
|
||||
'Flag',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.flag),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isBlocked ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
title: Text(
|
||||
isBlocked ? 'Unblock' : 'Block',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.block),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.cancel),
|
||||
),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
locator
|
||||
.get<AppReviewService>()
|
||||
.requestReview();
|
||||
context.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Okay',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
FeatherIcons.chevronUp,
|
||||
color: upvoted ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
title: Text(
|
||||
upvoted ? 'Upvoted' : 'Upvote',
|
||||
style: upvoted
|
||||
? TextStyle(color: Theme.of(context).primaryColor)
|
||||
: null,
|
||||
),
|
||||
subtitle: item is Story ? Text(item.score.toString()) : null,
|
||||
onTap: context.read<VoteCubit>().upvote,
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
FeatherIcons.chevronDown,
|
||||
color: downvoted ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
title: Text(
|
||||
downvoted ? 'Downvoted' : 'Downvote',
|
||||
style: downvoted
|
||||
? TextStyle(color: Theme.of(context).primaryColor)
|
||||
: null,
|
||||
),
|
||||
onTap: context.read<VoteCubit>().downvote,
|
||||
),
|
||||
BlocBuilder<FavCubit, FavState>(
|
||||
builder: (BuildContext context, FavState state) {
|
||||
final bool isFav = state.favIds.contains(item.id);
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFav ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
title: Text(
|
||||
isFav ? 'Unfavorite' : 'Favorite',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.fav),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(FeatherIcons.share),
|
||||
title: const Text(
|
||||
'Share',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.share),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.local_police),
|
||||
title: const Text(
|
||||
'Flag',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.flag),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isBlocked ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
title: Text(
|
||||
isBlocked ? 'Unblock' : 'Block',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.block),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
onTap: () => context.pop(MenuAction.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -245,6 +246,7 @@ class MorePopupMenu extends StatelessWidget {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (BuildContext context) {
|
||||
return BlocProvider<SearchCubit>(
|
||||
create: (_) => SearchCubit()
|
||||
@ -253,28 +255,16 @@ class MorePopupMenu extends StatelessWidget {
|
||||
author: item.by,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height - Dimens.pt120,
|
||||
color: Theme.of(context).canvasColor,
|
||||
margin: const EdgeInsets.only(top: Dimens.pt12),
|
||||
child: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
height: Dimens.pt4,
|
||||
width: Dimens.pt24,
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.grey,
|
||||
borderRadius: BorderRadius.circular(Dimens.pt16),
|
||||
),
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SearchScreen(
|
||||
fromUserDialog: true,
|
||||
),
|
||||
const Expanded(
|
||||
child: SearchScreen(
|
||||
fromUserDialog: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -76,7 +76,8 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
|
||||
duration: Durations.ms200,
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: <BoxShadow>[
|
||||
if (!context.read<SplitViewCubit>().state.enabled)
|
||||
if (!context.read<SplitViewCubit>().state.enabled &&
|
||||
!Theme.of(context).useMaterial3)
|
||||
BoxShadow(
|
||||
color: expanded ? Palette.transparent : Palette.black26,
|
||||
blurRadius: Dimens.pt40,
|
||||
@ -84,6 +85,9 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Theme.of(context).useMaterial3
|
||||
? Palette.transparent
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
|
@ -1,6 +1,7 @@
|
||||
export 'custom_app_bar.dart';
|
||||
export 'custom_floating_action_button.dart';
|
||||
export 'fav_icon_button.dart';
|
||||
export 'in_thread_search_icon_button.dart';
|
||||
export 'link_icon_button.dart';
|
||||
export 'login_dialog.dart';
|
||||
export 'main_view.dart';
|
||||
|
@ -102,15 +102,12 @@ class InboxView extends StatelessWidget {
|
||||
),
|
||||
Linkify(
|
||||
text: e.text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
color: unreadCommentsIds.contains(e.id)
|
||||
? textColor
|
||||
: Palette.grey,
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: unreadCommentsIds.contains(e.id)
|
||||
? textColor
|
||||
: Palette.grey,
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.primaryColor
|
||||
|
@ -53,202 +53,199 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
|
||||
},
|
||||
builder: (BuildContext context, SearchState state) {
|
||||
return Scaffold(
|
||||
backgroundColor: Palette.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt12,
|
||||
),
|
||||
child: TextField(
|
||||
cursorColor: Theme.of(context).primaryColor,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Hacker News',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt12,
|
||||
),
|
||||
child: TextField(
|
||||
cursorColor: Theme.of(context).primaryColor,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Hacker News',
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
onChanged: (String val) {
|
||||
if (val.isNotEmpty) {
|
||||
debouncer.run(() {
|
||||
context.read<SearchCubit>().search(val);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
onChanged: (String val) {
|
||||
if (val.isNotEmpty) {
|
||||
debouncer.run(() {
|
||||
context.read<SearchCubit>().search(val);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt6,
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
duration: chipsAnimationDuration,
|
||||
crossFadeState: state.showDateRangeShortcutChips
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: SizedBox.fromSize(),
|
||||
secondChild: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.dayBefore(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.dayAfter(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.weekBefore(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.weekAfter(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.monthBefore(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.monthAfter(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: Dimens.pt6,
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
duration: chipsAnimationDuration,
|
||||
crossFadeState: state.showDateRangeShortcutChips
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: SizedBox.fromSize(),
|
||||
secondChild: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.dayBefore(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.dayAfter(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.weekBefore(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.weekAfter(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.monthBefore(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeShortcutChip.monthAfter(
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
startDate: state.dateFilter?.startTime,
|
||||
endDate: state.dateFilter?.endTime,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeRangeFilterChip(
|
||||
filter: state.dateFilter,
|
||||
initialStartDate: state.dateFilter?.startTime,
|
||||
initialEndDate: state.dateFilter?.endTime,
|
||||
onDateTimeRangeUpdated: context
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
DateTimeRangeFilterChip(
|
||||
filter: state.dateFilter,
|
||||
initialStartDate: state.dateFilter?.startTime,
|
||||
initialEndDate: state.dateFilter?.endTime,
|
||||
onDateTimeRangeUpdated: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
onDateTimeRangeRemoved: context
|
||||
.read<SearchCubit>()
|
||||
.removeFilter<DateTimeRangeFilter>,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
PostedByFilterChip(
|
||||
filter: state.params.get<PostedByFilter>(),
|
||||
onChanged:
|
||||
context.read<SearchCubit>().onPostedByChanged,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
CustomChip(
|
||||
onSelected: (_) =>
|
||||
context.read<SearchCubit>().onSortToggled(),
|
||||
selected: state.params.sorted,
|
||||
label: '''newest first''',
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
for (final CustomDateTimeRange range
|
||||
in CustomDateTimeRange.values) ...<Widget>[
|
||||
CustomRangeFilterChip(
|
||||
range: range,
|
||||
onTap: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
onDateTimeRangeRemoved: context
|
||||
.read<SearchCubit>()
|
||||
.removeFilter<DateTimeRangeFilter>,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
PostedByFilterChip(
|
||||
filter: state.params.get<PostedByFilter>(),
|
||||
onChanged: context
|
||||
.read<SearchCubit>()
|
||||
.onPostedByChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
for (final TypeTagFilter filter
|
||||
in TypeTagFilter.all) ...<Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
CustomChip(
|
||||
onSelected: (_) =>
|
||||
context.read<SearchCubit>().onSortToggled(),
|
||||
selected: state.params.sorted,
|
||||
label: '''newest first''',
|
||||
onSelected: (_) => context
|
||||
.read<SearchCubit>()
|
||||
.onToggled(filter),
|
||||
selected: context
|
||||
.read<SearchCubit>()
|
||||
.state
|
||||
.params
|
||||
.get<TypeTagFilter>() ==
|
||||
filter,
|
||||
label: filter.query,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
for (final CustomDateTimeRange range
|
||||
in CustomDateTimeRange.values) ...<Widget>[
|
||||
CustomRangeFilterChip(
|
||||
range: range,
|
||||
onTap: context
|
||||
.read<SearchCubit>()
|
||||
.onDateTimeRangeUpdated,
|
||||
),
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
for (final TypeTagFilter filter
|
||||
in TypeTagFilter.all) ...<Widget>[
|
||||
const SizedBox(
|
||||
width: Dimens.pt8,
|
||||
),
|
||||
CustomChip(
|
||||
onSelected: (_) => context
|
||||
.read<SearchCubit>()
|
||||
.onToggled(filter),
|
||||
selected: context
|
||||
.read<SearchCubit>()
|
||||
.state
|
||||
.params
|
||||
.get<TypeTagFilter>() ==
|
||||
filter,
|
||||
label: filter.query,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.status == SearchStatus.loading &&
|
||||
state.results.isEmpty) ...<Widget>[
|
||||
|
@ -51,7 +51,10 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
backgroundColor: Theme.of(context).canvasColor,
|
||||
elevation: Dimens.zero,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
@ -83,8 +86,11 @@ class _SubmitScreenState extends State<SubmitScreen> with ItemActionMixin {
|
||||
});
|
||||
},
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'Submit',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
if (state.status == Status.inProgress)
|
||||
|
@ -20,6 +20,7 @@ class WebViewScreen extends StatefulWidget {
|
||||
|
||||
class _WebViewScreenState extends State<WebViewScreen> {
|
||||
final WebViewController controller = WebViewController();
|
||||
bool showFullUrl = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -43,15 +44,26 @@ class _WebViewScreenState extends State<WebViewScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).canvasColor,
|
||||
title: Text(
|
||||
humanize(widget.url),
|
||||
style: const TextStyle(
|
||||
fontSize: TextDimens.pt12,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
showFullUrl = !showFullUrl;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
showFullUrl
|
||||
? humanize(widget.url)
|
||||
: Uri.parse(widget.url).authority,
|
||||
style: const TextStyle(
|
||||
fontSize: TextDimens.pt14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
body: WebViewWidget(
|
||||
controller: controller,
|
||||
|
@ -32,6 +32,12 @@ class CenteredText extends StatelessWidget {
|
||||
text: 'blocked',
|
||||
);
|
||||
|
||||
const CenteredText.empty({Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
text: 'empty',
|
||||
);
|
||||
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
|
@ -10,7 +10,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class CommentTile extends StatelessWidget {
|
||||
const CommentTile({
|
||||
@ -26,18 +25,18 @@ class CommentTile extends StatelessWidget {
|
||||
this.collapsable = true,
|
||||
this.selectable = true,
|
||||
this.level = 0,
|
||||
this.index,
|
||||
this.onTap,
|
||||
this.itemScrollController,
|
||||
});
|
||||
|
||||
final String? opUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final int? index;
|
||||
final bool actionable;
|
||||
final bool collapsable;
|
||||
final bool selectable;
|
||||
final FetchMode fetchMode;
|
||||
final ItemScrollController? itemScrollController;
|
||||
|
||||
final void Function(Comment)? onReplyTapped;
|
||||
final void Function(Comment, Rect?)? onMoreTapped;
|
||||
@ -163,6 +162,15 @@ class CommentTile extends StatelessWidget {
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
if (index != null)
|
||||
Text(
|
||||
' #${index! + 1}',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
textScaleFactor:
|
||||
MediaQuery.of(context).textScaleFactor,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
comment.timeAgo,
|
||||
@ -351,7 +359,8 @@ class CommentTile extends StatelessWidget {
|
||||
final CollapseState collapseState = context.read<CollapseCubit>().state;
|
||||
final CommentsState? commentsState =
|
||||
context.tryRead<CommentsCubit>()?.state;
|
||||
return fetchMode == FetchMode.lazy &&
|
||||
return actionable &&
|
||||
fetchMode == FetchMode.lazy &&
|
||||
comment.kids.isNotEmpty &&
|
||||
collapseState.collapsed == false &&
|
||||
commentsState?.commentIds.contains(comment.kids.first) == false &&
|
||||
@ -370,14 +379,14 @@ class CommentTile extends StatelessWidget {
|
||||
..collapse(onStateChanged: HapticFeedbackUtil.selection);
|
||||
if (collapseCubit.state.collapsed &&
|
||||
preferenceCubit.state.autoScrollEnabled) {
|
||||
final List<Comment> comments =
|
||||
context.read<CommentsCubit>().state.comments;
|
||||
final CommentsCubit commentsCubit = context.read<CommentsCubit>();
|
||||
final List<Comment> comments = commentsCubit.state.comments;
|
||||
final int indexOfNextComment = comments.indexOf(comment) + 1;
|
||||
if (indexOfNextComment < comments.length) {
|
||||
Future<void>.delayed(
|
||||
Durations.ms300,
|
||||
() {
|
||||
itemScrollController?.scrollTo(
|
||||
commentsCubit.itemScrollController.scrollTo(
|
||||
index: indexOfNextComment,
|
||||
alignment: 0.1,
|
||||
duration: Durations.ms300,
|
||||
|
@ -15,13 +15,19 @@ class CustomChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool useMaterial3 = Theme.of(context).useMaterial3;
|
||||
return FilterChip(
|
||||
shadowColor: Palette.transparent,
|
||||
selectedShadowColor: Palette.transparent,
|
||||
backgroundColor: Palette.transparent,
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: Theme.of(context).primaryColor),
|
||||
),
|
||||
side: useMaterial3 && !selected
|
||||
? BorderSide(color: Theme.of(context).colorScheme.onSurface)
|
||||
: null,
|
||||
shape: Theme.of(context).useMaterial3
|
||||
? null
|
||||
: StadiumBorder(
|
||||
side: BorderSide(color: Theme.of(context).primaryColor),
|
||||
),
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
|
@ -25,10 +25,12 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
this.enablePullDown = true,
|
||||
this.markReadStories = false,
|
||||
this.showOfflineBanner = false,
|
||||
this.loadStyle = LoadStyle.ShowWhenLoading,
|
||||
this.onRefresh,
|
||||
this.onLoadMore,
|
||||
this.onPinned,
|
||||
this.header,
|
||||
this.footer,
|
||||
this.onMoreTapped,
|
||||
this.scrollController,
|
||||
this.itemBuilder,
|
||||
@ -43,8 +45,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
final bool markReadStories;
|
||||
final bool showOfflineBanner;
|
||||
|
||||
final LoadStyle loadStyle;
|
||||
final List<T> items;
|
||||
final Widget? header;
|
||||
final Widget? footer;
|
||||
final RefreshController refreshController;
|
||||
final ScrollController? scrollController;
|
||||
final VoidCallback? onRefresh;
|
||||
@ -105,10 +109,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
Linkify(
|
||||
text: e.title,
|
||||
maxLines: 4,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
@ -141,7 +144,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!showWebPreviewOnStoryTile)
|
||||
if (useSimpleTileForStory || !showWebPreviewOnStoryTile)
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
),
|
||||
@ -195,12 +198,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
Linkify(
|
||||
text: e.text,
|
||||
maxLines: 4,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
@ -228,6 +228,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
? Column(children: e)
|
||||
: itemBuilder!(Column(children: e), items.elementAt(index)),
|
||||
),
|
||||
if (footer != null) footer!,
|
||||
const SizedBox(
|
||||
height: Dimens.pt40,
|
||||
),
|
||||
@ -241,7 +242,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
footer: CustomFooter(
|
||||
loadStyle: LoadStyle.ShowWhenLoading,
|
||||
loadStyle: loadStyle,
|
||||
builder: (BuildContext context, LoadStatus? mode) {
|
||||
const double height = 55;
|
||||
late final Widget body;
|
||||
|
@ -22,6 +22,7 @@ class OfflineBanner extends StatelessWidget {
|
||||
builder: (BuildContext context, StoriesState state) {
|
||||
if (state.isOfflineReading) {
|
||||
return MaterialBanner(
|
||||
dividerColor: Palette.transparent,
|
||||
content: Text(
|
||||
'You are currently in offline mode. '
|
||||
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',
|
||||
|
@ -7,6 +7,7 @@ import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
@ -48,7 +49,8 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
|
||||
previous.metadataEnabled != current.metadataEnabled,
|
||||
previous.metadataEnabled != current.metadataEnabled ||
|
||||
previous.paginationEnabled != current.paginationEnabled,
|
||||
builder: (BuildContext context, PreferenceState preferenceState) {
|
||||
return BlocConsumer<StoriesBloc, StoriesState>(
|
||||
listenWhen: (StoriesState previous, StoriesState current) =>
|
||||
@ -70,8 +72,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
builder: (BuildContext context, StoriesState state) {
|
||||
return ItemsListView<Story>(
|
||||
showOfflineBanner: true,
|
||||
markReadStories:
|
||||
context.read<PreferenceCubit>().state.markReadStoriesEnabled,
|
||||
markReadStories: preferenceState.markReadStoriesEnabled,
|
||||
showWebPreviewOnStoryTile:
|
||||
preferenceState.complexStoryTileEnabled,
|
||||
showMetadataOnStoryTile: preferenceState.metadataEnabled,
|
||||
@ -87,13 +88,42 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
context.read<PinCubit>().refresh();
|
||||
},
|
||||
onLoadMore: () {
|
||||
context
|
||||
.read<StoriesBloc>()
|
||||
.add(StoriesLoadMore(type: storyType));
|
||||
if (preferenceState.paginationEnabled) {
|
||||
refreshController
|
||||
..refreshCompleted(resetFooterState: true)
|
||||
..loadComplete();
|
||||
} else {
|
||||
loadMoreStories();
|
||||
}
|
||||
},
|
||||
onTap: onStoryTapped,
|
||||
onPinned: context.read<PinCubit>().pinStory,
|
||||
header: state.isOfflineReading ? null : header,
|
||||
loadStyle: LoadStyle.HideAlways,
|
||||
footer: preferenceState.paginationEnabled &&
|
||||
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,
|
||||
itemBuilder: (Widget child, Story story) {
|
||||
return Slidable(
|
||||
@ -162,4 +192,7 @@ class _StoriesListViewState extends State<StoriesListView>
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void loadMoreStories() =>
|
||||
context.read<StoriesBloc>().add(StoriesLoadMore(type: widget.storyType));
|
||||
}
|
||||
|
@ -96,6 +96,7 @@ class StoryTile extends StatelessWidget {
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.color,
|
||||
fontWeight: hasRead ? null : FontWeight.w500,
|
||||
fontSize: simpleTileFontSize,
|
||||
),
|
||||
),
|
||||
|
@ -37,6 +37,7 @@ class _TapDownWrapperState extends State<TapDownWrapper>
|
||||
onTapDown: onTapDown,
|
||||
onTapUp: onTapUp,
|
||||
onTapCancel: onTapCancel,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedBuilder(
|
||||
animation:
|
||||
CurvedAnimation(parent: controller, curve: Curves.decelerate),
|
||||
|
@ -25,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.13.0"
|
||||
animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: animations
|
||||
sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1,6 +1,6 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 2.0.0+125
|
||||
version: 2.2.0+128
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@ -9,6 +9,7 @@ environment:
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.2.0
|
||||
animations: ^2.0.8
|
||||
badges: ^3.0.2
|
||||
bloc: ^8.1.1
|
||||
cached_network_image: ^3.2.3
|
||||
|
Reference in New Issue
Block a user