Compare commits

...

4 Commits

Author SHA1 Message Date
e77c0e3e73 update bottom sheet. (#190) 2023-03-31 23:15:53 -07:00
cb6f41ec49 add keyword filter. (#189) 2023-03-31 13:59:12 -07:00
ab1e90ccad fix comment tile and bottom navigation bar. (#187) 2023-03-26 19:16:38 -07:00
0ca3e96d91 update story tile. (#183) 2023-03-02 18:36:23 -08:00
41 changed files with 928 additions and 560 deletions

View File

@ -50,7 +50,7 @@ android {
defaultConfig { defaultConfig {
applicationId "com.jiaqifeng.hacki" applicationId "com.jiaqifeng.hacki"
minSdkVersion 30 minSdkVersion 26
targetSdkVersion 33 targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

View File

@ -2,6 +2,8 @@ PODS:
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- ReachabilitySwift - ReachabilitySwift
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_email_sender (0.0.1): - flutter_email_sender (0.0.1):
- Flutter - Flutter
@ -53,6 +55,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`) - flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
@ -81,6 +84,8 @@ SPEC REPOS:
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_email_sender: flutter_email_sender:
@ -120,6 +125,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721

View File

@ -17,11 +17,13 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> { class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({ StoriesBloc({
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger, Logger? logger,
}) : _preferenceCubit = preferenceCubit, }) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _storiesRepository =
@ -45,6 +47,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
@ -74,7 +77,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final int pageSize = getPageSize(isComplexTile: isComplexTile); final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit( emit(
const StoriesState.init().copyWith( const StoriesState.init().copyWith(
offlineReading: hasCachedStories && isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session. // Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial, state.downloadStatus == StoriesDownloadStatus.initial,
currentPageSize: pageSize, currentPageSize: pageSize,
@ -92,7 +95,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required StoryType type, required StoryType type,
required Emitter<StoriesState> emit, required Emitter<StoriesState> emit,
}) async { }) async {
if (state.offlineReading) { if (state.isOfflineReading) {
final List<int> ids = final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type); await _offlineRepository.getCachedStoryIds(type: type);
emit( emit(
@ -137,7 +140,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
), ),
); );
if (state.offlineReading) { if (state.isOfflineReading) {
emit( emit(
state.copyWithStatusUpdated( state.copyWithStatusUpdated(
type: event.type, type: event.type,
@ -172,7 +175,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
upper = len; upper = len;
} }
if (state.offlineReading) { if (state.isOfflineReading) {
_offlineRepository _offlineRepository
.getCachedStoriesStream( .getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist( ids: state.storyIdsByType[event.type]!.sublist(
@ -224,10 +227,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id); final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) =>
event.story.title.toLowerCase().contains(keyword) ||
event.story.text.toLowerCase().contains(keyword),
);
emit( emit(
state.copyWithStoryAdded( state.copyWithStoryAdded(
type: event.type, type: event.type,
story: event.story, story: event.story.copyWith(hidden: hidden),
hasRead: hasRead, hasRead: hasRead,
), ),
); );
@ -440,7 +448,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllStories(); await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments(); await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages(); await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(offlineReading: false)); emit(state.copyWith(isOfflineReading: false));
add(StoriesInitialize()); add(StoriesInitialize());
} }

View File

@ -21,7 +21,7 @@ class StoriesState extends Equatable {
required this.statusByType, required this.statusByType,
required this.currentPageByType, required this.currentPageByType,
required this.readStoriesIds, required this.readStoriesIds,
required this.offlineReading, required this.isOfflineReading,
required this.downloadStatus, required this.downloadStatus,
required this.currentPageSize, required this.currentPageSize,
required this.storiesDownloaded, required this.storiesDownloaded,
@ -57,7 +57,7 @@ class StoriesState extends Equatable {
StoryType.ask: 0, StoryType.ask: 0,
StoryType.show: 0, StoryType.show: 0,
}, },
}) : offlineReading = false, }) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.initial, downloadStatus = StoriesDownloadStatus.initial,
currentPageSize = 0, currentPageSize = 0,
readStoriesIds = const <int>{}, readStoriesIds = const <int>{},
@ -70,7 +70,7 @@ class StoriesState extends Equatable {
final Map<StoryType, int> currentPageByType; final Map<StoryType, int> currentPageByType;
final Set<int> readStoriesIds; final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus; final StoriesDownloadStatus downloadStatus;
final bool offlineReading; final bool isOfflineReading;
final int currentPageSize; final int currentPageSize;
final int storiesDownloaded; final int storiesDownloaded;
final int storiesToBeDownloaded; final int storiesToBeDownloaded;
@ -82,7 +82,7 @@ class StoriesState extends Equatable {
Map<StoryType, int>? currentPageByType, Map<StoryType, int>? currentPageByType,
Set<int>? readStoriesIds, Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus, StoriesDownloadStatus? downloadStatus,
bool? offlineReading, bool? isOfflineReading,
int? currentPageSize, int? currentPageSize,
int? storiesDownloaded, int? storiesDownloaded,
int? storiesToBeDownloaded, int? storiesToBeDownloaded,
@ -93,7 +93,7 @@ class StoriesState extends Equatable {
statusByType: statusByType ?? this.statusByType, statusByType: statusByType ?? this.statusByType,
currentPageByType: currentPageByType ?? this.currentPageByType, currentPageByType: currentPageByType ?? this.currentPageByType,
readStoriesIds: readStoriesIds ?? this.readStoriesIds, readStoriesIds: readStoriesIds ?? this.readStoriesIds,
offlineReading: offlineReading ?? this.offlineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
downloadStatus: downloadStatus ?? this.downloadStatus, downloadStatus: downloadStatus ?? this.downloadStatus,
currentPageSize: currentPageSize ?? this.currentPageSize, currentPageSize: currentPageSize ?? this.currentPageSize,
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded, storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
@ -183,7 +183,7 @@ class StoriesState extends Equatable {
statusByType, statusByType,
currentPageByType, currentPageByType,
readStoriesIds, readStoriesIds,
offlineReading, isOfflineReading,
downloadStatus, downloadStatus,
currentPageSize, currentPageSize,
storiesDownloaded, storiesDownloaded,

View File

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart'; import 'package:hacki/main.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';
@ -20,17 +21,19 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache, required CollapseCache collapseCache,
CommentCache? commentCache, CommentCache? commentCache,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
Logger? logger, Logger? logger,
required bool offlineReading, required bool isOfflineReading,
required Item item, required Item item,
required FetchMode defaultFetchMode, required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder, required CommentsOrder defaultCommentsOrder,
}) : _collapseCache = collapseCache, }) : _filterCubit = filterCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
@ -41,13 +44,14 @@ class CommentsCubit extends Cubit<CommentsState> {
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
super( super(
CommentsState.init( CommentsState.init(
offlineReading: offlineReading, isOfflineReading: isOfflineReading,
item: item, item: item,
fetchMode: defaultFetchMode, fetchMode: defaultFetchMode,
order: defaultCommentsOrder, order: defaultCommentsOrder,
), ),
); );
final FilterCubit _filterCubit;
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
@ -109,7 +113,7 @@ class CommentsCubit extends Cubit<CommentsState> {
); );
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.offlineReading final Item updatedItem = state.isOfflineReading
? item ? item
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ?? : await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
item; item;
@ -119,7 +123,7 @@ class CommentsCubit extends Cubit<CommentsState> {
late final Stream<Comment> commentStream; late final Stream<Comment> commentStream;
if (state.offlineReading) { if (state.isOfflineReading) {
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids); commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
} else { } else {
switch (state.fetchMode) { switch (state.fetchMode) {
@ -152,7 +156,7 @@ class CommentsCubit extends Cubit<CommentsState> {
), ),
); );
if (state.offlineReading) { if (state.isOfflineReading) {
emit( emit(
state.copyWith( state.copyWith(
status: CommentsStatus.allLoaded, status: CommentsStatus.allLoaded,
@ -348,9 +352,12 @@ class CommentsCubit extends Cubit<CommentsState> {
_commentCache.cacheComment(comment); _commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment); _sembastRepository.cacheComment(comment);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),
);
final List<Comment> updatedComments = <Comment>[ final List<Comment> updatedComments = <Comment>[
...state.comments, ...state.comments,
comment comment.copyWith(hidden: hidden),
]; ];
emit(state.copyWith(comments: updatedComments)); emit(state.copyWith(comments: updatedComments));

View File

@ -17,12 +17,12 @@ class CommentsState extends Equatable {
required this.order, required this.order,
required this.fetchMode, required this.fetchMode,
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.offlineReading, required this.isOfflineReading,
required this.currentPage, required this.currentPage,
}); });
CommentsState.init({ CommentsState.init({
required this.offlineReading, required this.isOfflineReading,
required this.item, required this.item,
required this.fetchMode, required this.fetchMode,
required this.order, required this.order,
@ -39,7 +39,7 @@ class CommentsState extends Equatable {
final CommentsOrder order; final CommentsOrder order;
final FetchMode fetchMode; final FetchMode fetchMode;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool offlineReading; final bool isOfflineReading;
final int currentPage; final int currentPage;
CommentsState copyWith({ CommentsState copyWith({
@ -50,7 +50,7 @@ class CommentsState extends Equatable {
CommentsOrder? order, CommentsOrder? order,
FetchMode? fetchMode, FetchMode? fetchMode,
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? offlineReading, bool? isOfflineReading,
int? currentPage, int? currentPage,
}) { }) {
return CommentsState( return CommentsState(
@ -62,7 +62,7 @@ class CommentsState extends Equatable {
fetchMode: fetchMode ?? this.fetchMode, fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment: onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
} }
@ -77,7 +77,7 @@ class CommentsState extends Equatable {
order, order,
fetchMode, fetchMode,
onlyShowTargetComment, onlyShowTargetComment,
offlineReading, isOfflineReading,
currentPage, currentPage,
comments, comments,
]; ];

View File

@ -3,6 +3,7 @@ export 'collapse/collapse_cubit.dart';
export 'comments/comments_cubit.dart'; export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart'; export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart'; export 'fav/fav_cubit.dart';
export 'filter/filter_cubit.dart';
export 'history/history_cubit.dart'; export 'history/history_cubit.dart';
export 'notification/notification_cubit.dart'; export 'notification/notification_cubit.dart';
export 'pin/pin_cubit.dart'; export 'pin/pin_cubit.dart';

View File

@ -0,0 +1,40 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/repositories.dart';
part 'filter_state.dart';
class FilterCubit extends Cubit<FilterState> {
FilterCubit({PreferenceRepository? preferenceRepository})
: _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
super(FilterState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
void init() {
_preferenceRepository.filterKeywords.then(
(List<String> keywords) => emit(
state.copyWith(
keywords: keywords.toSet(),
),
),
);
}
void addKeyword(String keyword) {
final Set<String> updated = Set<String>.from(state.keywords)..add(keyword);
emit(state.copyWith(keywords: updated));
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
}
void removeKeyword(String keyword) {
final Set<String> updated = Set<String>.from(state.keywords)
..remove(keyword);
emit(state.copyWith(keywords: updated));
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
}
}

View File

@ -0,0 +1,20 @@
part of 'filter_cubit.dart';
class FilterState extends Equatable {
const FilterState({
required this.keywords,
});
FilterState.init() : keywords = <String>{};
final Set<String> keywords;
FilterState copyWith({Set<String>? keywords}) {
return FilterState(
keywords: keywords ?? this.keywords,
);
}
@override
List<Object?> get props => <Object?>[keywords];
}

View File

@ -6,8 +6,6 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
extension ContextExtension on BuildContext { extension ContextExtension on BuildContext {
bool get isScreenReaderEnabled => MediaQuery.of(this).accessibleNavigation;
T? tryRead<T>() { T? tryRead<T>() {
try { try {
return read<T>(); return read<T>();

View File

@ -2,7 +2,7 @@ import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
extension ObjectExtension on Object { extension ObjectExtension on Object {
void log({String identifier = ''}) { void log([String identifier = '']) {
locator.get<Logger>().d('$identifier ${toString()}'); locator.get<Logger>().d('$identifier ${toString()}');
} }

View File

@ -58,10 +58,12 @@ extension StateExtension on State {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (BuildContext context) { builder: (BuildContext context) {
return MorePopupMenu( return SafeArea(
item: item, child: MorePopupMenu(
isBlocked: isBlocked, item: item,
onLoginTapped: onLoginTapped, isBlocked: isBlocked,
onLoginTapped: onLoginTapped,
),
); );
}, },
).then((MenuAction? action) { ).then((MenuAction? action) {
@ -106,24 +108,26 @@ extension StateExtension on State {
linkToShare = await showModalBottomSheet<String>( linkToShare = await showModalBottomSheet<String>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Container( return SafeArea(
height: 140, child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
child: Column( child: Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
ListTile( children: <Widget>[
onTap: () => Navigator.pop(context, item.url), ListTile(
title: const Text('Link to article'), onTap: () => Navigator.pop(context, item.url),
), title: const Text('Link to article'),
ListTile(
onTap: () => Navigator.pop(
context,
'https://news.ycombinator.com/item?id=${item.id}',
), ),
title: const Text('Link to HN'), ListTile(
), onTap: () => Navigator.pop(
], context,
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
),
), ),
), ),
); );

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -110,13 +111,27 @@ Future<void> main({bool testing = false}) async {
}, },
); );
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle( final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
const SystemUiOverlayStyle( final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
statusBarColor: Palette.transparent, final int sdk = androidInfo.version.sdkInt;
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent, if (sdk > 28) {
), SystemChrome.setSystemUIOverlayStyle(
); const SystemUiOverlayStyle(
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);
} else {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent,
),
);
}
await SystemChrome.setEnabledSystemUIMode( await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge, SystemUiMode.edgeToEdge,
@ -168,9 +183,14 @@ class HackiApp extends StatelessWidget {
lazy: false, lazy: false,
create: (BuildContext context) => PreferenceCubit(), create: (BuildContext context) => PreferenceCubit(),
), ),
BlocProvider<FilterCubit>(
lazy: false,
create: (BuildContext context) => FilterCubit(),
),
BlocProvider<StoriesBloc>( BlocProvider<StoriesBloc>(
create: (BuildContext context) => StoriesBloc( create: (BuildContext context) => StoriesBloc(
preferenceCubit: context.read<PreferenceCubit>(), preferenceCubit: context.read<PreferenceCubit>(),
filterCubit: context.read<FilterCubit>(),
), ),
), ),
BlocProvider<AuthBloc>( BlocProvider<AuthBloc>(

View File

@ -15,6 +15,7 @@ class BuildableComment extends Comment with Buildable {
required super.kids, required super.kids,
required super.dead, required super.dead,
required super.deleted, required super.deleted,
required super.hidden,
required super.level, required super.level,
required this.elements, required this.elements,
}); });
@ -31,6 +32,7 @@ class BuildableComment extends Comment with Buildable {
dead: comment.dead, dead: comment.dead,
deleted: comment.deleted, deleted: comment.deleted,
level: comment.level, level: comment.level,
hidden: comment.hidden,
); );
@override @override

View File

@ -17,6 +17,7 @@ class BuildableStory extends Story with Buildable {
required super.type, required super.type,
required super.url, required super.url,
required super.parts, required super.parts,
required super.hidden,
required this.elements, required this.elements,
}); });
@ -33,6 +34,7 @@ class BuildableStory extends Story with Buildable {
type: story.type, type: story.type,
url: story.url, url: story.url,
parts: story.parts, parts: story.parts,
hidden: story.hidden,
); );
BuildableStory.fromTitleOnlyStory(Story story) BuildableStory.fromTitleOnlyStory(Story story)

View File

@ -11,6 +11,7 @@ class Comment extends Item {
required super.kids, required super.kids,
required super.dead, required super.dead,
required super.deleted, required super.deleted,
required super.hidden,
required this.level, required this.level,
}) : super( }) : super(
descendants: 0, descendants: 0,
@ -26,7 +27,10 @@ class Comment extends Item {
String get metadata => '''by $by $timeAgo'''; String get metadata => '''by $by $timeAgo''';
Comment copyWith({int? level}) { Comment copyWith({
int? level,
bool? hidden,
}) {
return Comment( return Comment(
id: id, id: id,
time: time, time: time,
@ -37,6 +41,7 @@ class Comment extends Item {
kids: kids, kids: kids,
dead: dead, dead: dead,
deleted: deleted, deleted: deleted,
hidden: hidden ?? this.hidden,
level: level ?? this.level, level: level ?? this.level,
); );
} }

View File

@ -28,6 +28,7 @@ class Item extends Equatable {
required this.type, required this.type,
required this.parts, required this.parts,
required this.descendants, required this.descendants,
required this.hidden,
}); });
Item.empty() Item.empty()
@ -39,9 +40,10 @@ class Item extends Equatable {
title = '', title = '',
url = '', url = '',
kids = <int>[], kids = <int>[],
dead = false,
parts = <int>[], parts = <int>[],
dead = false,
deleted = false, deleted = false,
hidden = false,
parent = 0, parent = 0,
text = '', text = '',
type = ''; type = '';
@ -60,7 +62,8 @@ class Item extends Equatable {
deleted = json['deleted'] as bool? ?? false, deleted = json['deleted'] as bool? ?? false,
parent = json['parent'] as int? ?? 0, parent = json['parent'] as int? ?? 0,
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[], parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
type = json['type'] as String? ?? ''; type = json['type'] as String? ?? '',
hidden = json['hidden'] as bool? ?? false;
final int id; final int id;
final int time; final int time;
@ -73,6 +76,11 @@ class Item extends Equatable {
final bool deleted; final bool deleted;
final bool dead; final bool dead;
/// Whether or not the item should be hidden.
/// true if any of filter keywords set by user presents in [text]
/// or [title].
final bool hidden;
final String by; final String by;
final String text; final String text;
final String url; final String url;
@ -128,5 +136,6 @@ class Item extends Equatable {
type, type,
parts, parts,
descendants, descendants,
hidden,
]; ];
} }

View File

@ -20,6 +20,7 @@ class PollOption extends Item {
descendants: 0, descendants: 0,
dead: false, dead: false,
deleted: false, deleted: false,
hidden: false,
); );
PollOption.empty() PollOption.empty()

View File

@ -14,6 +14,7 @@ class Story extends Item {
required super.text, required super.text,
required super.kids, required super.kids,
required super.parts, required super.parts,
required super.hidden,
}) : super( }) : super(
dead: false, dead: false,
deleted: false, deleted: false,
@ -38,15 +39,33 @@ class Story extends Item {
parent: 0, parent: 0,
text: '', text: '',
type: '', type: '',
hidden: false,
); );
Story.fromJson(super.json) : super.fromJson(); Story.fromJson(super.json) : super.fromJson();
Story copyWith({bool? hidden}) {
return Story(
descendants: descendants,
id: id,
score: score,
time: time,
by: by,
title: title,
type: type,
url: url,
text: text,
kids: kids,
parts: parts,
hidden: hidden ?? this.hidden,
);
}
String get metadata => String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}'''; '''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
String get screenReaderLabel => String get screenReaderLabel =>
'''$title at $readableUrl by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}'''; '''$title, at $readableUrl, by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}''';
String get simpleMetadata => String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $timeAgo'''; '''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $timeAgo''';

View File

@ -17,7 +17,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences = static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>( UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[ <Preference<dynamic>>[
// Order of these first four preferences does not matter. // Order of these preferences does not matter.
FetchModePreference(), FetchModePreference(),
CommentsOrderPreference(), CommentsOrderPreference(),
FontPreference(), FontPreference(),

View File

@ -22,6 +22,7 @@ class PreferenceRepository {
static const String _usernameKey = 'username'; static const String _usernameKey = 'username';
static const String _passwordKey = 'password'; static const String _passwordKey = 'password';
static const String _blocklistKey = 'blocklist'; static const String _blocklistKey = 'blocklist';
static const String _filterKeywordsKey = 'filterKeywords';
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds'; static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds'; static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _lastReadStoryIdKey = 'lastReadStoryId'; static const String _lastReadStoryIdKey = 'lastReadStoryId';
@ -274,6 +275,20 @@ class PreferenceRepository {
//#endregion //#endregion
//#region filter
Future<List<String>> get filterKeywords async => _prefs.then(
(SharedPreferences prefs) =>
prefs.getStringList(_filterKeywordsKey) ?? <String>[],
);
Future<void> updateFilterKeywords(List<String> keywords) async {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(_filterKeywordsKey, keywords);
}
//#endregion
//#region pins //#region pins
Future<List<int>> get pinnedStoriesIds async { Future<List<int>> get pinnedStoriesIds async {

View File

@ -58,6 +58,7 @@ class SearchRepository {
parent: parentId, parent: parentId,
dead: false, dead: false,
deleted: false, deleted: false,
hidden: false,
level: 0, level: 0,
); );
yield comment; yield comment;
@ -80,6 +81,7 @@ class SearchRepository {
// response doesn't contain kids and parts. // response doesn't contain kids and parts.
kids: const <int>[], kids: const <int>[],
parts: const <int>[], parts: const <int>[],
hidden: false,
); );
yield story; yield story;
} }

View File

@ -214,7 +214,7 @@ class _HomeScreenState extends State<HomeScreen>
context.read<PreferenceCubit>().state.webFirstEnabled; context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled; final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading = final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading; context.read<StoriesBloc>().state.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story); final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled; final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
@ -225,7 +225,9 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) { if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId(); context.read<ReminderCubit>().removeLastReadStoryId();
} else { } else {
final ItemScreenArgs args = ItemScreenArgs(item: story); final ItemScreenArgs args = ItemScreenArgs(
item: story,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id); context.read<ReminderCubit>().updateLastReadStoryId(story.id);

View File

@ -58,14 +58,15 @@ class ItemScreen extends StatefulWidget {
return MaterialPageRoute<ItemScreen>( return MaterialPageRoute<ItemScreen>(
settings: const RouteSettings(name: routeName), settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => RepositoryProvider<CollapseCache>( builder: (BuildContext context) => RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(), create: (_) => CollapseCache(),
lazy: false, lazy: false,
child: MultiBlocProvider( child: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
offlineReading: filterCubit: context.read<FilterCubit>(),
context.read<StoriesBloc>().state.offlineReading, isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
collapseCache: context.read<CollapseCache>(), collapseCache: context.read<CollapseCache>(),
defaultFetchMode: defaultFetchMode:
@ -99,15 +100,16 @@ class ItemScreen extends StatefulWidget {
} }
}, },
child: RepositoryProvider<CollapseCache>( child: RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(), create: (_) => CollapseCache(),
lazy: false, lazy: false,
child: MultiBlocProvider( child: MultiBlocProvider(
key: ValueKey<ItemScreenArgs>(args), key: ValueKey<ItemScreenArgs>(args),
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
offlineReading: filterCubit: context.read<FilterCubit>(),
context.read<StoriesBloc>().state.offlineReading, isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
collapseCache: context.read<CollapseCache>(), collapseCache: context.read<CollapseCache>(),
defaultFetchMode: defaultFetchMode:
@ -434,45 +436,45 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
} }
void onRightMoreTapped(Comment comment) { void onRightMoreTapped(Comment comment) {
const double bottomSheetHeight = 140;
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Container( return SafeArea(
height: bottomSheetHeight, child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,
child: Column( child: Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
ListTile( children: <Widget>[
leading: const Icon(Icons.av_timer), ListTile(
title: const Text('View ancestors'), leading: const Icon(Icons.av_timer),
onTap: () { title: const Text('View ancestors'),
Navigator.pop(context); onTap: () {
onTimeMachineActivated(comment); Navigator.pop(context);
}, onTimeMachineActivated(comment);
enabled: },
comment.level > 0 && !(comment.dead || comment.deleted), enabled:
), comment.level > 0 && !(comment.dead || comment.deleted),
ListTile( ),
leading: const Icon(Icons.list), ListTile(
title: const Text('View in separate thread'), leading: const Icon(Icons.list),
onTap: () { title: const Text('View in separate thread'),
Navigator.pop(context); onTap: () {
goToItemScreen( Navigator.pop(context);
args: ItemScreenArgs( goToItemScreen(
item: comment, args: ItemScreenArgs(
useCommentCache: true, item: comment,
), useCommentCache: true,
forceNewScreen: true, ),
); forceNewScreen: true,
}, );
enabled: !(comment.dead || comment.deleted), },
), enabled: !(comment.dead || comment.deleted),
], ),
],
),
), ),
), ),
); );

View File

@ -87,7 +87,7 @@ class MainView extends StatelessWidget {
onRefresh: () { onRefresh: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.offlineReading) { if (context.read<StoriesBloc>().state.isOfflineReading) {
refreshController.refreshCompleted(); refreshController.refreshCompleted();
} else { } else {
context.read<CommentsCubit>().refresh(); context.read<CommentsCubit>().refresh();
@ -231,232 +231,257 @@ class _ParentItemSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Semantics(
children: <Widget>[ label:
SizedBox( '''Posted by ${state.item.by} ${state.item.timeAgo}, ${state.item.title}. ${state.item.text}''',
height: topPadding, child: Column(
), children: <Widget>[
if (!splitViewEnabled) SizedBox(
const Padding( height: topPadding,
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
), ),
Slidable( if (!splitViewEnabled)
startActionPane: ActionPane( const Padding(
motion: const BehindMotion(), padding: EdgeInsets.only(bottom: Dimens.pt6),
children: <Widget>[ child: OfflineBanner(),
SlidableAction( ),
onPressed: (_) { Slidable(
HapticFeedback.lightImpact(); startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (state.item.id != if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) { context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear(); commentEditingController.clear();
} }
context.read<EditCubit>().onReplyTapped(state.item); context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus(); focusNode.requestFocus();
}, },
backgroundColor: Palette.orange, backgroundColor: Palette.orange,
foregroundColor: Palette.white, foregroundColor: Palette.white,
icon: Icons.message, icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
), ),
child: Row( SlidableAction(
children: <Widget>[ onPressed: (BuildContext context) =>
Text( onMoreTapped(state.item, context.rect),
state.item.by, backgroundColor: Palette.orange,
style: const TextStyle( foregroundColor: Palette.white,
color: Palette.orange, icon: Icons.more_horiz,
),
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
),
],
), ),
), ],
BlocBuilder<PreferenceCubit, PreferenceState>( ),
buildWhen: ( child: Column(
PreferenceState previous, children: <Widget>[
PreferenceState current, Padding(
) => padding: const EdgeInsets.only(
previous.fontSize != current.fontSize, left: Dimens.pt6,
builder: ( right: Dimens.pt6,
BuildContext context, ),
PreferenceState prefState, child: Row(
) {
return Column(
children: <Widget>[ children: <Widget>[
if (state.item is Story) Text(
InkWell( state.item.by,
onTap: () => LinkUtil.launch( style: const TextStyle(
state.item.url, color: Palette.orange,
useReader: context ),
.read<PreferenceCubit>() ),
.state const Spacer(),
.readerEnabled, Text(
offlineReading: context state.item.timeAgo,
.read<StoriesBloc>() style: const TextStyle(
.state color: Palette.grey,
.offlineReading, ),
), ),
child: Padding( ],
padding: const EdgeInsets.only( ),
left: Dimens.pt6, ),
right: Dimens.pt6, BlocBuilder<PreferenceCubit, PreferenceState>(
bottom: Dimens.pt12, buildWhen: (
top: Dimens.pt12, PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
), ),
child: Text.rich( child: Padding(
TextSpan( padding: const EdgeInsets.only(
style: TextStyle( left: Dimens.pt6,
fontWeight: FontWeight.bold, right: Dimens.pt6,
fontSize: prefState.fontSize.fontSize, bottom: Dimens.pt12,
color: Theme.of(context) top: Dimens.pt12,
.textTheme ),
.bodyLarge child: Text.rich(
?.color, TextSpan(
), style: TextStyle(
children: <TextSpan>[ fontWeight: FontWeight.bold,
TextSpan( fontSize: prefState.fontSize.fontSize,
text: state.item.title, color: Theme.of(context)
style: TextStyle( .textTheme
fontWeight: FontWeight.bold, .bodyLarge
fontSize: prefState.fontSize.fontSize, ?.color,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
), ),
if (state.item.url.isNotEmpty) children: <TextSpan>[
TextSpan( TextSpan(
text: semanticsLabel: state.item.title,
''' (${(state.item as Story).readableUrl})''', text: state.item.title,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: fontSize: prefState.fontSize.fontSize,
prefState.fontSize.fontSize - 4, color: state.item.url.isNotEmpty
color: Palette.orange, ? Palette.orange
: null,
), ),
), ),
], if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
), ),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
), ),
), ),
) ],
else );
const SizedBox( },
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
), ),
], if (state.item.isPoll)
), BlocProvider<PollCubit>(
), create: (BuildContext context) =>
if (state.item.text.isNotEmpty) PollCubit(story: state.item as Story)..init(),
const SizedBox( child: const PollView(),
height: Dimens.pt8, ),
), ],
const Divider(
height: Dimens.zero,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(state.item as Story),
child: const Text('View all comments'),
), ),
), ),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,
), ),
] else ...<Widget>[ if (state.onlyShowTargetComment) ...<Widget>[
Row( Center(
children: <Widget>[ child: TextButton(
if (state.item is Story) ...<Widget>[ onPressed: () =>
const SizedBox( context.read<CommentsCubit>().loadAll(state.item as Story),
width: Dimens.pt12, child: const Text('View all comments'),
), ),
Text( ),
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''', const Divider(
style: const TextStyle( height: Dimens.zero,
fontSize: TextDimens.pt13, ),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
), ),
), Text(
] else ...<Widget>[ '''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
const SizedBox( style: const TextStyle(
width: Dimens.pt4, fontSize: TextDimens.pt13,
), ),
TextButton( ),
onPressed: context.read<CommentsCubit>().loadParentThread, ] else ...<Widget>[
child: state.fetchParentStatus == CommentsStatus.loading const SizedBox(
? const SizedBox( width: Dimens.pt4,
height: Dimens.pt12, ),
width: Dimens.pt12, TextButton(
child: CustomCircularProgressIndicator( onPressed: context.read<CommentsCubit>().loadParentThread,
strokeWidth: Dimens.pt2, child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
),
],
const Spacer(),
if (!state.isOfflineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
), ),
) )
: const Text( .toList(),
'View parent thread', onChanged: context.read<CommentsCubit>().onFetchModeChanged,
style: TextStyle( ),
fontSize: TextDimens.pt13, const SizedBox(
), width: Dimens.pt6,
),
), ),
], DropdownButton<CommentsOrder>(
const Spacer(), value: state.order,
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(), underline: const SizedBox.shrink(),
items: FetchMode.values items: CommentsOrder.values
.map( .map(
(FetchMode val) => DropdownMenuItem<FetchMode>( (CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val, value: val,
child: Text( child: Text(
val.description, val.description,
@ -467,51 +492,31 @@ class _ParentItemSection extends StatelessWidget {
), ),
) )
.toList(), .toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged, onChanged: context.read<CommentsCubit>().onOrderChanged,
), ),
const SizedBox( const SizedBox(
width: Dimens.pt6, width: Dimens.pt4,
), ),
DropdownButton<CommentsOrder>( ],
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
), ),
), const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
),
),
],
], ],
], ),
); );
} }
} }

View File

@ -23,9 +23,6 @@ class MorePopupMenu extends StatelessWidget {
final bool isBlocked; final bool isBlocked;
final VoidCallback onLoginTapped; final VoidCallback onLoginTapped;
static const double _storySheetHeight = 500;
static const double _commentSheetHeight = 480;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<VoteCubit>( return BlocProvider<VoteCubit>(
@ -69,75 +66,83 @@ 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 Container( return ColoredBox(
height: item is Comment ? _commentSheetHeight : _storySheetHeight,
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
BlocProvider<UserCubit>( BlocProvider<UserCubit>(
create: (BuildContext context) => create: (BuildContext context) =>
UserCubit()..init(userId: item.by), UserCubit()..init(userId: item.by),
child: BlocBuilder<UserCubit, UserState>( child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) { builder: (BuildContext context, UserState state) {
return ListTile( return Semantics(
leading: const Icon( excludeSemantics: state.status == UserStatus.loading,
Icons.account_circle, child: ListTile(
), leading: const Icon(
title: Text(item.by), Icons.account_circle,
subtitle: Text( ),
state.user.description, title: Text(item.by),
), subtitle: Text(
onTap: () { state.user.description,
Navigator.pop(context); ),
showDialog<void>( onTap: () {
context: context, Navigator.pop(context);
builder: (BuildContext context) => AlertDialog( showDialog<void>(
title: Text('About ${state.user.id}'), context: context,
content: state.user.about.isEmpty builder: (BuildContext context) => AlertDialog(
? Row( semanticLabel:
mainAxisAlignment: '''About ${state.user.id}. ${state.user.about}''',
MainAxisAlignment.center, title: Text(
children: const <Widget>[ 'About ${state.user.id}',
Text( ),
'empty', content: state.user.about.isEmpty
style: TextStyle( ? Row(
color: Palette.grey, mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
), ),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
), ),
], linkStyle: const TextStyle(
) color: Palette.orange,
: SelectableLinkify( ),
text: HtmlUtil.parseHtml( onOpen: (LinkableElement link) =>
state.user.about, LinkUtil.launch(link.url),
semanticsLabel: state.user.about,
), ),
linkStyle: const TextStyle( actions: <Widget>[
color: Palette.orange, TextButton(
), onPressed: () {
onOpen: (LinkableElement link) => Navigator.pop(context);
LinkUtil.launch(link.url), onSearchUserTapped(context);
},
child: const Text(
'Search',
), ),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
), ),
), TextButton(
TextButton( onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(context), child: const Text(
child: const Text( 'Okay',
'Okay', ),
), ),
), ],
], ),
), );
); },
}, ),
); );
}, },
), ),

View File

@ -34,6 +34,7 @@ class _ScrollUpIconButtonState extends State<ScrollUpIconButton> {
return Opacity( return Opacity(
opacity: opacity.clamp(0, 1), opacity: opacity.clamp(0, 1),
child: IconButton( child: IconButton(
tooltip: 'Scroll to top',
icon: const Icon( icon: const Icon(
FeatherIcons.chevronsUp, FeatherIcons.chevronsUp,
color: Palette.orange, color: Palette.orange,

View File

@ -232,6 +232,12 @@ class _SettingsState extends State<Settings> {
onTap: showThemeSettingDialog, onTap: showThemeSettingDialog,
), ),
const Divider(), const Divider(),
ListTile(
title: const Text(
'Filter Keywords',
),
onTap: onFilterKeywordsTapped,
),
ListTile( ListTile(
title: const Text( title: const Text(
'Export Favorites', 'Export Favorites',
@ -640,6 +646,100 @@ class _SettingsState extends State<Settings> {
} }
} }
void onFilterKeywordsTapped() {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'Filter Keywords',
style: TextStyle(
fontSize: TextDimens.pt16,
),
),
content: BlocBuilder<FilterCubit, FilterState>(
builder: (BuildContext context, FilterState state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (state.keywords.isEmpty)
const CenteredText(
text:
'''story or comment that contains keywords here will be hidden.''',
),
Wrap(
spacing: Dimens.pt4,
children: <Widget>[
for (final String keyword in state.keywords)
ActionChip(
avatar: const Icon(
Icons.close,
size: TextDimens.pt14,
),
label: Text(keyword),
onPressed: () => context
.read<FilterCubit>()
.removeKeyword(keyword),
),
],
),
],
);
},
),
actions: <Widget>[
TextButton(
onPressed: onAddKeywordTapped,
child: const Text(
'Add keyword',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Okay',
),
),
],
);
},
);
}
void onAddKeywordTapped() {
final TextEditingController controller = TextEditingController();
showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
content: TextField(
autofocus: true,
controller: controller,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
final String keyword = controller.text.trim();
if (keyword.isEmpty) return;
context.read<FilterCubit>().addKeyword(keyword.toLowerCase());
Navigator.pop(context);
},
child: const Text(
'Confirm',
),
),
],
);
},
);
}
Future<void> onExportFavoritesTapped() async { Future<void> onExportFavoritesTapped() async {
final List<int> allFavorites = context.read<FavCubit>().state.favIds; final List<int> allFavorites = context.read<FavCubit>().state.favIds;

View File

@ -43,6 +43,7 @@ class CommentTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (comment.hidden) return const SizedBox.shrink();
return BlocProvider<CollapseCubit>( return BlocProvider<CollapseCubit>(
key: ValueKey<String>('${comment.id}-BlocProvider'), key: ValueKey<String>('${comment.id}-BlocProvider'),
lazy: false, lazy: false,
@ -188,16 +189,19 @@ class CommentTile extends StatelessWidget {
), ),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: ItemText( child: Semantics(
key: ValueKey<int>(comment.id), label: '''At level ${comment.level}.''',
item: comment, child: ItemText(
onTap: () { key: ValueKey<int>(comment.id),
if (onTap == null) { item: comment,
_onTextTapped(context); onTap: () {
} else { if (onTap == null) {
onTap!.call(); _onTextTapped(context);
} } else {
}, onTap!.call();
}
},
),
), ),
), ),
), ),

View File

@ -153,6 +153,7 @@ class SelectableLinkify extends StatelessWidget {
const SelectableLinkify({ const SelectableLinkify({
super.key, super.key,
required this.text, required this.text,
this.semanticsLabel,
this.linkifiers = defaultLinkifiers, this.linkifiers = defaultLinkifiers,
this.onOpen, this.onOpen,
this.options = LinkifierUtil.linkifyOptions, this.options = LinkifierUtil.linkifyOptions,
@ -188,6 +189,8 @@ class SelectableLinkify extends StatelessWidget {
/// Text to be linkified /// Text to be linkified
final String text; final String text;
final String? semanticsLabel;
/// The number of font pixels for each logical pixel /// The number of font pixels for each logical pixel
final double textScaleFactor; final double textScaleFactor;
@ -317,7 +320,7 @@ class SelectableLinkify extends StatelessWidget {
selectionControls: selectionControls, selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged, onSelectionChanged: onSelectionChanged,
contextMenuBuilder: contextMenuBuilder, contextMenuBuilder: contextMenuBuilder,
semanticsLabel: text, semanticsLabel: semanticsLabel,
); );
} }

View File

@ -66,6 +66,7 @@ class ItemText extends StatelessWidget {
editableTextState, editableTextState,
item: item, item: item,
), ),
semanticsLabel: item.text,
); );
} }
} }

View File

@ -16,10 +16,9 @@ class LinkPreview extends StatefulWidget {
required this.story, required this.story,
required this.showMetadata, required this.showMetadata,
required this.showUrl, required this.showUrl,
required this.offlineReading, required this.isOfflineReading,
required this.titleStyle,
this.cache = const Duration(days: 30), this.cache = const Duration(days: 30),
this.titleStyle,
this.bodyStyle,
this.showMultimedia = true, this.showMultimedia = true,
this.backgroundColor = const Color.fromRGBO(235, 235, 235, 1), this.backgroundColor = const Color.fromRGBO(235, 235, 235, 1),
this.bodyMaxLines = 3, this.bodyMaxLines = 3,
@ -84,10 +83,7 @@ class LinkPreview extends StatefulWidget {
final Duration cache; final Duration cache;
/// Customize body `TextStyle` /// Customize body `TextStyle`
final TextStyle? titleStyle; final TextStyle titleStyle;
/// Customize body `TextStyle`
final TextStyle? bodyStyle;
/// Show or Hide image if available defaults to `true` /// Show or Hide image if available defaults to `true`
final bool showMultimedia; final bool showMultimedia;
@ -105,7 +101,7 @@ class LinkPreview extends StatefulWidget {
final bool showMetadata; final bool showMetadata;
final bool showUrl; final bool showUrl;
final bool offlineReading; final bool isOfflineReading;
@override @override
_LinkPreviewState createState() => _LinkPreviewState(); _LinkPreviewState createState() => _LinkPreviewState();
@ -135,7 +131,7 @@ class _LinkPreviewState extends State<LinkPreview> {
_info = await WebAnalyzer.getInfo( _info = await WebAnalyzer.getInfo(
story: widget.story, story: widget.story,
cache: widget.cache, cache: widget.cache,
offlineReading: widget.offlineReading, offlineReading: widget.isOfflineReading,
); );
if (mounted) { if (mounted) {
@ -190,7 +186,6 @@ class _LinkPreviewState extends State<LinkPreview> {
imagePath: Constants.hackerNewsLogoPath, imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL, onTap: _launchURL,
titleTextStyle: widget.titleStyle, titleTextStyle: widget.titleStyle,
bodyTextStyle: widget.bodyStyle,
bodyTextOverflow: widget.bodyTextOverflow, bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines, bodyMaxLines: widget.bodyMaxLines,
showMultiMedia: widget.showMultimedia, showMultiMedia: widget.showMultimedia,

View File

@ -1,9 +1,11 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/models/models.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:memoize/memoize.dart';
class LinkView extends StatelessWidget { class LinkView extends StatelessWidget {
LinkView({ LinkView({
@ -17,10 +19,9 @@ class LinkView extends StatelessWidget {
required this.showMetadata, required this.showMetadata,
required bool showUrl, required bool showUrl,
required this.bodyMaxLines, required this.bodyMaxLines,
required this.titleTextStyle,
this.imageUri, this.imageUri,
this.imagePath, this.imagePath,
this.titleTextStyle,
this.bodyTextStyle,
this.showMultiMedia = true, this.showMultiMedia = true,
this.bodyTextOverflow, this.bodyTextOverflow,
this.isIcon = false, this.isIcon = false,
@ -41,8 +42,7 @@ class LinkView extends StatelessWidget {
final String? imageUri; final String? imageUri;
final String? imagePath; final String? imagePath;
final void Function(String) onTap; final void Function(String) onTap;
final TextStyle? titleTextStyle; final TextStyle titleTextStyle;
final TextStyle? bodyTextStyle;
final bool showMultiMedia; final bool showMultiMedia;
final TextOverflow? bodyTextOverflow; final TextOverflow? bodyTextOverflow;
final int bodyMaxLines; final int bodyMaxLines;
@ -52,36 +52,90 @@ class LinkView extends StatelessWidget {
final bool showMetadata; final bool showMetadata;
final bool showUrl; final bool showUrl;
static final double Function(double) _getTitleFontSize = static const double _bottomPadding = 6;
memo1(_computeTitleFontSize); static late TextStyle _urlStyle;
static late TextStyle _metadataStyle;
static late TextStyle _descriptionStyle;
static double _computeTitleFontSize(double width) { static final Map<MaxLineComputationParams, int> _computationCache =
double size = width * 0.13; <MaxLineComputationParams, int>{};
if (size > 15) {
size = 15;
}
return size;
}
static final int Function(double) _getTitleLines = memo1(_computeTitleLines); static int getDescriptionMaxLines(
MaxLineComputationParams params,
static int _computeTitleLines(double layoutHeight) { TextStyle titleStyle,
return layoutHeight >= 100 ? 2 : 1;
}
static final int Function(int, bool, bool, String?) _getBodyLines =
memo4(_computeBodyLines);
static int _computeBodyLines(
int bodyMaxLines,
bool showMetadata,
bool showUrl,
String? fontFamily,
) { ) {
final int maxLines = bodyMaxLines - if (_computationCache.containsKey(params)) {
(showMetadata ? 1 : 0) - return _computationCache[params]!;
(showUrl ? 1 : 0) + }
(fontFamily == Font.ubuntuMono.name ? 1 : 0);
_urlStyle = titleStyle.copyWith(
color: Palette.grey,
fontSize: TextDimens.pt12,
fontWeight: FontWeight.w400,
fontFamily: params.fontFamily,
);
_descriptionStyle = TextStyle(
color: Palette.grey,
fontFamily: params.fontFamily,
fontSize: TextDimens.pt14,
);
_metadataStyle = _descriptionStyle.copyWith(
fontSize: TextDimens.pt12,
fontFamily: params.fontFamily,
);
final double urlHeight = (TextPainter(
text: TextSpan(
text: '(url)',
style: _urlStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double metadataHeight = (TextPainter(
text: TextSpan(
text: '123metadata',
style: _metadataStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double descriptionHeight = (TextPainter(
text: TextSpan(
text: 'DESCRIPTION',
style: _descriptionStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double allPaddings =
params.fontFamily == Font.robotoSlab.name ? Dimens.pt2 : Dimens.pt4;
final double height = <double>[
params.titleHeight,
if (params.showUrl) urlHeight,
if (params.showMetadata) metadataHeight,
allPaddings,
_bottomPadding,
].reduce((double a, double b) => a + b);
final double descriptionAllowedHeight = params.layoutHeight - height;
final int maxLines =
max(1, (descriptionAllowedHeight / descriptionHeight).floor());
_computationCache[params] = maxLines;
return maxLines; return maxLines;
} }
@ -91,19 +145,36 @@ class LinkView extends StatelessWidget {
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
final double layoutWidth = constraints.biggest.width; final double layoutWidth = constraints.biggest.width;
final double layoutHeight = constraints.biggest.height; final double layoutHeight = constraints.biggest.height;
final double bodyWidth = layoutWidth - layoutHeight - 8;
final String? fontFamily =
Theme.of(context).primaryTextTheme.bodyMedium?.fontFamily;
final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
final TextStyle titleFontStyle = titleTextStyle ?? final TextStyle titleStyle = titleTextStyle;
TextStyle( final double titleHeight = (TextPainter(
fontSize: _getTitleFontSize(layoutWidth), text: TextSpan(
color: Palette.black, text: title,
fontWeight: FontWeight.bold, style: titleStyle,
); ),
final TextStyle bodyFontStyle = bodyTextStyle ?? maxLines: 2,
TextStyle( textScaleFactor: textScaleFactor,
fontSize: _getTitleFontSize(layoutWidth) - 1, textDirection: TextDirection.ltr,
color: Palette.grey, )..layout(maxWidth: bodyWidth))
fontWeight: FontWeight.w400, .size
); .height;
final int descriptionMaxLines = getDescriptionMaxLines(
MaxLineComputationParams(
fontFamily ?? Font.roboto.name,
bodyWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
),
titleStyle,
);
return InkWell( return InkWell(
onTap: () => onTap(url), onTap: () => onTap(url),
@ -138,85 +209,51 @@ class LinkView extends StatelessWidget {
), ),
) )
else else
const SizedBox(width: 5), const SizedBox(width: Dimens.pt5),
Expanded( SizedBox(
height: layoutHeight,
width: layoutWidth - layoutHeight - 8,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Padding( SizedBox(
padding: EdgeInsets.only( height:
top: Theme.of(context) Theme.of(context).textTheme.bodyMedium?.fontFamily ==
.textTheme Font.robotoSlab.name
.bodyMedium ? Dimens.pt2
?.fontFamily == : Dimens.pt4,
Font.robotoSlab.name
? 2
: 4,
),
child: Column(
children: <Widget>[
Container(
alignment: Alignment.topLeft,
child: Text(
title,
style: titleFontStyle,
overflow: TextOverflow.ellipsis,
maxLines: _getTitleLines(layoutHeight),
),
),
if (showUrl && url.isNotEmpty)
Container(
alignment: Alignment.topLeft,
child: Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: titleFontStyle.copyWith(
color: Palette.grey,
fontSize: titleFontStyle.fontSize == null
? 12
: titleFontStyle.fontSize! - 4,
fontWeight: FontWeight.w400,
),
overflow:
bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
), ),
Text(
title,
style: titleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
if (showUrl)
Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: _urlStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
if (showMetadata) if (showMetadata)
Container( Text(
alignment: Alignment.topLeft, metadata,
margin: const EdgeInsets.only(top: 2), textAlign: TextAlign.left,
child: Text( style: _metadataStyle,
metadata, overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
textAlign: TextAlign.left, maxLines: 1,
style: bodyFontStyle.copyWith(
fontSize: bodyFontStyle.fontSize == null
? 12
: bodyFontStyle.fontSize! - 2,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
),
Expanded(
child: Container(
alignment: Alignment.topLeft,
child: Text(
description,
textAlign: TextAlign.left,
style: bodyFontStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: _getBodyLines(
bodyMaxLines,
showMetadata,
showUrl,
Theme.of(context).textTheme.bodyMedium?.fontFamily,
),
),
), ),
Text(
description,
textAlign: TextAlign.left,
style: _descriptionStyle,
overflow: TextOverflow.ellipsis,
maxLines: descriptionMaxLines,
),
const SizedBox(
height: _bottomPadding,
), ),
], ],
), ),

View File

@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
class MaxLineComputationParams extends Equatable {
const MaxLineComputationParams(
this.fontFamily,
this.layoutWidth,
this.layoutHeight,
this.titleHeight,
this.textScaleFactor,
// ignore: avoid_positional_boolean_parameters
this.showUrl,
this.showMetadata,
);
final String fontFamily;
final double layoutWidth;
final double layoutHeight;
final double titleHeight;
final double textScaleFactor;
final bool showUrl;
final bool showMetadata;
@override
List<Object?> get props => <Object>[
fontFamily,
layoutWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
];
}

View File

@ -0,0 +1 @@
export 'max_line_computation_params.dart';

View File

@ -17,9 +17,9 @@ class OfflineBanner extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>( return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (StoriesState previous, StoriesState current) => buildWhen: (StoriesState previous, StoriesState current) =>
previous.offlineReading != current.offlineReading, previous.isOfflineReading != current.isOfflineReading,
builder: (BuildContext context, StoriesState state) { builder: (BuildContext context, StoriesState state) {
if (state.offlineReading) { if (state.isOfflineReading) {
return MaterialBanner( return MaterialBanner(
content: Text( content: Text(
'You are currently in offline mode. ' 'You are currently in offline mode. '

View File

@ -86,7 +86,7 @@ class _StoriesListViewState extends State<StoriesListView> {
}, },
onTap: onStoryTapped, onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory, onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header, header: state.isOfflineReading ? null : header,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
); );
}, },

View File

@ -31,10 +31,12 @@ class StoryTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (story.hidden) return const SizedBox.shrink();
if (showWebPreview) { if (showWebPreview) {
final double height = context.storyTileHeight; final double height = context.storyTileHeight;
return Semantics( return Semantics(
label: story.screenReaderLabel, label: story.screenReaderLabel,
excludeSemantics: true,
child: TapDownWrapper( child: TapDownWrapper(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
@ -45,8 +47,8 @@ class StoryTile extends StatelessWidget {
child: LinkPreview( child: LinkPreview(
story: story, story: story,
link: story.url, link: story.url,
offlineReading: isOfflineReading:
context.read<StoriesBloc>().state.offlineReading, context.read<StoriesBloc>().state.isOfflineReading,
placeholderWidget: _LinkPreviewPlaceholder( placeholderWidget: _LinkPreviewPlaceholder(
height: height, height: height,
), ),
@ -72,6 +74,7 @@ class StoryTile extends StatelessWidget {
} else { } else {
return Semantics( return Semantics(
label: story.screenReaderLabel, label: story.screenReaderLabel,
excludeSemantics: true,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(

View File

@ -13,10 +13,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: adaptive_theme name: adaptive_theme
sha256: "84af26cfc68220df3cd35d9d94cb8953e7182ef560e13d8efb87f32bf1e588fc" sha256: "61bde10390e937d11d05c6cf0d5cf378a73d49f9a442262e43613dae60ed0b3f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" version: "3.2.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
@ -201,6 +201,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.8" version: "0.7.8"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "1d6e5a61674ba3a68fb048a7c7b4ff4bebfed8d7379dbe8f2b718231be9a7c95"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64
url: "https://pub.dev"
source: hosted
version: "7.0.0"
diff_match_patch: diff_match_patch:
dependency: transitive dependency: transitive
description: description:
@ -213,10 +229,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8" sha256: "3e5c4a94d112540d0c9a6b7f3969832e1604eb8cde0f88d0808382f9f632100b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.6" version: "5.0.3"
equatable: equatable:
dependency: "direct main" dependency: "direct main"
description: description:
@ -572,10 +588,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: logger name: logger
sha256: "5076f09225f91dc49289a4ccb92df2eeea9ea01cf7c26d49b3a1f04c6a49eec1" sha256: db2ff852ed77090ba9f62d3611e4208a3d11dfa35991a81ae724c113fcb3e3f7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.3.0"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@ -1359,4 +1375,4 @@ packages:
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.19.0 <3.0.0" dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.6" flutter: ">=3.7.9"

View File

@ -1,21 +1,22 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.3.2+101 version: 1.4.0+104
publish_to: none publish_to: none
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.6" flutter: "3.7.9"
dependencies: dependencies:
adaptive_theme: ^3.0.0 adaptive_theme: ^3.2.0
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
clipboard: ^0.1.3 clipboard: ^0.1.3
collection: ^1.17.0 collection: ^1.17.0
connectivity_plus: ^3.0.2 connectivity_plus: ^3.0.2
dio: ^4.0.6 device_info_plus: ^8.1.0
dio: ^5.0.3
equatable: ^2.0.5 equatable: ^2.0.5
fast_gbk: ^1.0.0 fast_gbk: ^1.0.0
feature_discovery: feature_discovery:
@ -44,7 +45,7 @@ dependencies:
hydrated_bloc: ^9.1.0 hydrated_bloc: ^9.1.0
intl: ^0.18.0 intl: ^0.18.0
linkify: ^4.1.0 linkify: ^4.1.0
logger: ^1.1.0 logger: ^1.3.0
memoize: ^3.0.0 memoize: ^3.0.0
package_info_plus: ^3.0.3 package_info_plus: ^3.0.3
path: ^1.8.2 path: ^1.8.2