Compare commits

..

9 Commits

47 changed files with 1013 additions and 658 deletions

View File

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

View File

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

View File

@ -41,7 +41,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) {
final String? username = await _authRepository.username;
final User user = await _storiesRepository.fetchUser(id: username!);
User? user = await _storiesRepository.fetchUser(id: username!);
/// According to Hacker News' API documentation,
/// if user has no public activity (posting a comment or story),
/// then it will not be available from the API.
user ??= User.emptyWithId(username);
emit(
state.copyWith(
@ -84,10 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
);
if (successful) {
final User user = await _storiesRepository.fetchUser(id: event.username);
final User? user = await _storiesRepository.fetchUser(id: event.username);
emit(
state.copyWith(
user: user,
user: user ?? User.emptyWithId(event.username),
isLoggedIn: true,
status: AuthStatus.loaded,
),

View File

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

View File

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

View File

@ -7,7 +7,7 @@ abstract class Constants {
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
static const String portfolioLink = 'https://livinglist.github.io';
static const String portfolioLink = 'https://github.com/Livinglist';
static const String githubLink = 'https://github.com/Livinglist/Hacki';
static const String appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';

View File

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

View File

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

View File

@ -3,6 +3,7 @@ export 'collapse/collapse_cubit.dart';
export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart';
export 'filter/filter_cubit.dart';
export 'history/history_cubit.dart';
export 'notification/notification_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

@ -52,8 +52,6 @@ class PreferenceState extends Equatable {
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get webFirstEnabled => _isOn<NavigationModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();

View File

@ -16,8 +16,13 @@ class UserCubit extends Cubit<UserState> {
void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading));
_storiesRepository.fetchUser(id: userId).then((User user) {
emit(state.copyWith(user: user, status: UserStatus.loaded));
_storiesRepository.fetchUser(id: userId).then((User? user) {
emit(
state.copyWith(
user: user ?? User.emptyWithId(userId),
status: UserStatus.loaded,
),
);
}).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure));
return;

View File

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

View File

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

View File

@ -58,10 +58,12 @@ extension StateExtension on State {
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
onLoginTapped: onLoginTapped,
return SafeArea(
child: MorePopupMenu(
item: item,
isBlocked: isBlocked,
onLoginTapped: onLoginTapped,
),
);
},
).then((MenuAction? action) {
@ -106,24 +108,26 @@ extension StateExtension on State {
linkToShare = await showModalBottomSheet<String>(
context: context,
builder: (BuildContext context) {
return Container(
height: 140,
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
children: <Widget>[
ListTile(
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}',
return SafeArea(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
onTap: () => Navigator.pop(context, item.url),
title: const Text('Link to article'),
),
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 'package:adaptive_theme/adaptive_theme.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
@ -110,13 +111,27 @@ Future<void> main({bool testing = false}) async {
},
);
} else if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
final int sdk = androidInfo.version.sdkInt;
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(
SystemUiMode.edgeToEdge,
@ -168,9 +183,14 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (BuildContext context) => PreferenceCubit(),
),
BlocProvider<FilterCubit>(
lazy: false,
create: (BuildContext context) => FilterCubit(),
),
BlocProvider<StoriesBloc>(
create: (BuildContext context) => StoriesBloc(
preferenceCubit: context.read<PreferenceCubit>(),
filterCubit: context.read<FilterCubit>(),
),
),
BlocProvider<AuthBloc>(

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ class Item extends Equatable {
required this.type,
required this.parts,
required this.descendants,
required this.hidden,
});
Item.empty()
@ -39,9 +40,10 @@ class Item extends Equatable {
title = '',
url = '',
kids = <int>[],
dead = false,
parts = <int>[],
dead = false,
deleted = false,
hidden = false,
parent = 0,
text = '',
type = '';
@ -60,7 +62,8 @@ class Item extends Equatable {
deleted = json['deleted'] as bool? ?? false,
parent = json['parent'] as int? ?? 0,
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 time;
@ -73,6 +76,11 @@ class Item extends Equatable {
final bool deleted;
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 text;
final String url;
@ -128,5 +136,6 @@ class Item extends Equatable {
type,
parts,
descendants,
hidden,
];
}

View File

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

View File

@ -14,6 +14,7 @@ class Story extends Item {
required super.text,
required super.kids,
required super.parts,
required super.hidden,
}) : super(
dead: false,
deleted: false,
@ -38,15 +39,33 @@ class Story extends Item {
parent: 0,
text: '',
type: '',
hidden: false,
);
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 =>
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
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 =>
'''$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 =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these first four preferences does not matter.
// Order of these preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
@ -31,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
const NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
@ -54,7 +53,6 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValue = false;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true;
@ -189,29 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.''';
}
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {
const NavigationModePreference({bool? val})
: super(
val: val ?? _navigationModeDefaultValue,
);
@override
NavigationModePreference copyWith({required bool? val}) {
return NavigationModePreference(val: val);
}
@override
String get key => 'navigationMode';
@override
String get title => 'Show Web Page First';
@override
String get subtitle => '''show web page first after tapping on story.''';
}
class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue);

View File

@ -17,6 +17,12 @@ class User extends Equatable {
id = '',
karma = 0;
const User.emptyWithId(this.id)
: about = '',
created = 0,
delay = 0,
karma = 0;
User.fromJson(Map<String, dynamic> json)
: about = json['about'] as String? ?? '',
created = json['created'] as int? ?? 0,

View File

@ -22,6 +22,7 @@ class PreferenceRepository {
static const String _usernameKey = 'username';
static const String _passwordKey = 'password';
static const String _blocklistKey = 'blocklist';
static const String _filterKeywordsKey = 'filterKeywords';
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _lastReadStoryIdKey = 'lastReadStoryId';
@ -274,6 +275,20 @@ class PreferenceRepository {
//#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
Future<List<int>> get pinnedStoriesIds async {

View File

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

View File

@ -74,11 +74,14 @@ class StoriesRepository {
/// Fetch a [User] by its [id].
/// Hacker News uses user's username as [id].
Future<User> fetchUser({required String id}) async {
final User user = await _firebaseClient
Future<User?> fetchUser({required String id}) async {
final User? user = await _firebaseClient
.get('${_baseUrl}user/$id.json')
.then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Map<String, dynamic>? json = val as Map<String, dynamic>?;
if (json == null) return null;
final User user = User.fromJson(json);
return user;
});

View File

@ -210,12 +210,9 @@ class _HomeScreenState extends State<HomeScreen>
}
void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
context.read<StoriesBloc>().state.isOfflineReading;
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
@ -225,7 +222,9 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
final ItemScreenArgs args = ItemScreenArgs(
item: story,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
@ -243,7 +242,7 @@ class _HomeScreenState extends State<HomeScreen>
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
if (story.url.isNotEmpty && isJobWithLink) {
LinkUtil.launch(
story.url,
useReader: useReader,

View File

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

View File

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

View File

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

View File

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

View File

@ -232,6 +232,12 @@ class _SettingsState extends State<Settings> {
onTap: showThemeSettingDialog,
),
const Divider(),
ListTile(
title: const Text(
'Filter Keywords',
),
onTap: onFilterKeywordsTapped,
),
ListTile(
title: const Text(
'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 {
final List<int> allFavorites = context.read<FavCubit>().state.favIds;

View File

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

View File

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

View File

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

View File

@ -7,19 +7,18 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkPreview extends StatefulWidget {
const LinkPreview({
super.key,
required this.link,
required this.story,
required this.onTap,
required this.showMetadata,
required this.showUrl,
required this.offlineReading,
required this.isOfflineReading,
required this.titleStyle,
this.cache = const Duration(days: 30),
this.titleStyle,
this.bodyStyle,
this.showMultimedia = true,
this.backgroundColor = const Color.fromRGBO(235, 235, 235, 1),
this.bodyMaxLines = 3,
@ -35,6 +34,7 @@ class LinkPreview extends StatefulWidget {
});
final Story story;
final VoidCallback onTap;
/// Web address (Url that need to be parsed)
/// For IOS & Web, only HTTP and HTTPS are support
@ -84,10 +84,7 @@ class LinkPreview extends StatefulWidget {
final Duration cache;
/// Customize body `TextStyle`
final TextStyle? titleStyle;
/// Customize body `TextStyle`
final TextStyle? bodyStyle;
final TextStyle titleStyle;
/// Show or Hide image if available defaults to `true`
final bool showMultimedia;
@ -105,7 +102,7 @@ class LinkPreview extends StatefulWidget {
final bool showMetadata;
final bool showUrl;
final bool offlineReading;
final bool isOfflineReading;
@override
_LinkPreviewState createState() => _LinkPreviewState();
@ -135,7 +132,7 @@ class _LinkPreviewState extends State<LinkPreview> {
_info = await WebAnalyzer.getInfo(
story: widget.story,
cache: widget.cache,
offlineReading: widget.offlineReading,
offlineReading: widget.isOfflineReading,
);
if (mounted) {
@ -145,19 +142,6 @@ class _LinkPreviewState extends State<LinkPreview> {
}
}
Future<void> _launchURL(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
try {
await launchUrl(uri);
} catch (err) {
throw Exception('Could not launch $url. Error: $err');
}
}
}
Widget _buildLinkContainer(
double height, {
String? title = '',
@ -188,9 +172,8 @@ class _LinkPreviewState extends State<LinkPreview> {
description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri,
imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL,
onTap: widget.onTap,
titleTextStyle: widget.titleStyle,
bodyTextStyle: widget.bodyStyle,
bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines,
showMultiMedia: widget.showMultimedia,

View File

@ -1,9 +1,13 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/models/models.dart';
import 'package:hacki/screens/widgets/tap_down_wrapper.dart';
import 'package:hacki/styles/styles.dart';
import 'package:memoize/memoize.dart';
import 'package:hacki/utils/link_util.dart';
class LinkView extends StatelessWidget {
LinkView({
@ -17,10 +21,9 @@ class LinkView extends StatelessWidget {
required this.showMetadata,
required bool showUrl,
required this.bodyMaxLines,
required this.titleTextStyle,
this.imageUri,
this.imagePath,
this.titleTextStyle,
this.bodyTextStyle,
this.showMultiMedia = true,
this.bodyTextOverflow,
this.isIcon = false,
@ -40,9 +43,8 @@ class LinkView extends StatelessWidget {
final String description;
final String? imageUri;
final String? imagePath;
final void Function(String) onTap;
final TextStyle? titleTextStyle;
final TextStyle? bodyTextStyle;
final VoidCallback onTap;
final TextStyle titleTextStyle;
final bool showMultiMedia;
final TextOverflow? bodyTextOverflow;
final int bodyMaxLines;
@ -52,36 +54,90 @@ class LinkView extends StatelessWidget {
final bool showMetadata;
final bool showUrl;
static final double Function(double) _getTitleFontSize =
memo1(_computeTitleFontSize);
static const double _bottomPadding = 6;
static late TextStyle _urlStyle;
static late TextStyle _metadataStyle;
static late TextStyle _descriptionStyle;
static double _computeTitleFontSize(double width) {
double size = width * 0.13;
if (size > 15) {
size = 15;
}
return size;
}
static final Map<MaxLineComputationParams, int> _computationCache =
<MaxLineComputationParams, int>{};
static final int Function(double) _getTitleLines = memo1(_computeTitleLines);
static int _computeTitleLines(double layoutHeight) {
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,
static int getDescriptionMaxLines(
MaxLineComputationParams params,
TextStyle titleStyle,
) {
final int maxLines = bodyMaxLines -
(showMetadata ? 1 : 0) -
(showUrl ? 1 : 0) +
(fontFamily == Font.ubuntuMono.name ? 1 : 0);
if (_computationCache.containsKey(params)) {
return _computationCache[params]!;
}
_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;
}
@ -91,31 +147,57 @@ class LinkView extends StatelessWidget {
builder: (BuildContext context, BoxConstraints constraints) {
final double layoutWidth = constraints.biggest.width;
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 ??
TextStyle(
fontSize: _getTitleFontSize(layoutWidth),
color: Palette.black,
fontWeight: FontWeight.bold,
);
final TextStyle bodyFontStyle = bodyTextStyle ??
TextStyle(
fontSize: _getTitleFontSize(layoutWidth) - 1,
color: Palette.grey,
fontWeight: FontWeight.w400,
);
final TextStyle titleStyle = titleTextStyle;
final double titleHeight = (TextPainter(
text: TextSpan(
text: title,
style: titleStyle,
),
maxLines: 2,
textScaleFactor: textScaleFactor,
textDirection: TextDirection.ltr,
)..layout(maxWidth: bodyWidth))
.size
.height;
return InkWell(
onTap: () => onTap(url),
child: Row(
children: <Widget>[
if (showMultiMedia)
Padding(
padding: const EdgeInsets.only(
right: 8,
top: 5,
bottom: 5,
),
final int descriptionMaxLines = getDescriptionMaxLines(
MaxLineComputationParams(
fontFamily ?? Font.roboto.name,
bodyWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
),
titleStyle,
);
return Row(
children: <Widget>[
if (showMultiMedia)
Padding(
padding: const EdgeInsets.only(
right: 8,
top: 5,
bottom: 5,
),
child: TapDownWrapper(
onTap: () {
if (url.isNotEmpty) {
LinkUtil.launch(
url,
useHackiForHnLink: false,
);
} else {
onTap();
}
},
child: SizedBox(
height: layoutHeight,
width: layoutHeight,
@ -136,93 +218,62 @@ class LinkView extends StatelessWidget {
},
),
),
)
else
const SizedBox(width: 5),
Expanded(
),
)
else
const SizedBox(width: Dimens.pt5),
TapDownWrapper(
onTap: onTap,
child: SizedBox(
height: layoutHeight,
width: layoutWidth - layoutHeight - 8,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: EdgeInsets.only(
top: Theme.of(context)
.textTheme
.bodyMedium
?.fontFamily ==
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,
),
),
],
),
SizedBox(
height:
Theme.of(context).textTheme.bodyMedium?.fontFamily ==
Font.robotoSlab.name
? Dimens.pt2
: Dimens.pt4,
),
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)
Container(
alignment: Alignment.topLeft,
margin: const EdgeInsets.only(top: 2),
child: Text(
metadata,
textAlign: TextAlign.left,
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(
metadata,
textAlign: TextAlign.left,
style: _metadataStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
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) {
return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (StoriesState previous, StoriesState current) =>
previous.offlineReading != current.offlineReading,
previous.isOfflineReading != current.isOfflineReading,
builder: (BuildContext context, StoriesState state) {
if (state.offlineReading) {
if (state.isOfflineReading) {
return MaterialBanner(
content: Text(
'You are currently in offline mode. '

View File

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

View File

@ -31,47 +31,46 @@ class StoryTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (story.hidden) return const SizedBox.shrink();
if (showWebPreview) {
final double height = context.storyTileHeight;
return Semantics(
label: story.screenReaderLabel,
child: TapDownWrapper(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
excludeSemantics: true,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: LinkPreview(
story: story,
link: story.url,
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
placeholderWidget: _LinkPreviewPlaceholder(
height: height,
),
child: AbsorbPointer(
child: LinkPreview(
story: story,
link: story.url,
offlineReading:
context.read<StoriesBloc>().state.offlineReading,
placeholderWidget: _LinkPreviewPlaceholder(
height: height,
),
errorImage: Constants.hackerNewsLogoLink,
backgroundColor: Palette.transparent,
borderRadius: Dimens.zero,
removeElevation: true,
bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title,
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
),
errorImage: Constants.hackerNewsLogoLink,
backgroundColor: Palette.transparent,
borderRadius: Dimens.zero,
removeElevation: true,
bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title,
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
onTap: onTap,
),
),
);
} else {
return Semantics(
label: story.screenReaderLabel,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
child: Padding(

View File

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

View File

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