Compare commits

...

8 Commits

38 changed files with 500 additions and 272 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;
@ -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,
),
);

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,18 +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 isScreenReaderEnabled,
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>(),
@ -40,7 +42,6 @@ class CommentsCubit extends Cubit<CommentsState> {
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
_isScreenReaderEnabled = isScreenReaderEnabled,
super(
CommentsState.init(
isOfflineReading: isOfflineReading,
@ -50,13 +51,13 @@ class CommentsCubit extends Cubit<CommentsState> {
),
);
final FilterCubit _filterCubit;
final CollapseCache _collapseCache;
final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository;
final Logger _logger;
final bool _isScreenReaderEnabled;
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
@ -351,17 +352,17 @@ 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));
if (state.fetchMode == FetchMode.eager) {
/// If screen reader is on, fetch all the comments without paging.
if (_isScreenReaderEnabled) return;
if (updatedComments.length >=
_pageSize + _pageSize * state.currentPage &&
updatedComments.length <=

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>();
@ -21,8 +19,6 @@ extension ContextExtension on BuildContext {
VoidCallback? action,
String? label,
}) {
if (isScreenReaderEnabled) return;
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,

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,10 +39,28 @@ 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' : ''}''';

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

@ -92,14 +92,12 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (context.isScreenReaderEnabled == false) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -212,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.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
@ -229,7 +224,6 @@ class _HomeScreenState extends State<HomeScreen>
} else {
final ItemScreenArgs args = ItemScreenArgs(
item: story,
isScreenReaderEnabled: context.isScreenReaderEnabled,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
@ -248,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

@ -23,13 +23,11 @@ class ItemScreenArgs extends Equatable {
required this.item,
this.onlyShowTargetComment = false,
this.useCommentCache = false,
this.isScreenReaderEnabled = false,
this.targetComments,
});
final Item item;
final bool onlyShowTargetComment;
final bool isScreenReaderEnabled;
final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't
@ -41,7 +39,6 @@ class ItemScreenArgs extends Equatable {
List<Object?> get props => <Object?>[
item,
onlyShowTargetComment,
isScreenReaderEnabled,
targetComments,
useCommentCache,
];
@ -67,6 +64,7 @@ class ItemScreen extends StatefulWidget {
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
@ -75,7 +73,6 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
isScreenReaderEnabled: args.isScreenReaderEnabled,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
@ -110,6 +107,7 @@ class ItemScreen extends StatefulWidget {
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
@ -118,7 +116,6 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
isScreenReaderEnabled: args.isScreenReaderEnabled,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
@ -178,16 +175,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (context.isScreenReaderEnabled == false) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -325,8 +320,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<SplitViewCubit>().zoom,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
isScreenReaderEnabled:
context.isScreenReaderEnabled,
),
);
},
@ -362,7 +355,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
scrollController: scrollController,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
isScreenReaderEnabled: context.isScreenReaderEnabled,
),
body: MainView(
scrollController: scrollController,
@ -444,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

@ -16,7 +16,6 @@ class CustomAppBar extends AppBar {
bool splitViewEnabled = false,
VoidCallback? onZoomTap,
bool? expanded,
bool isScreenReaderEnabled = false,
}) : super(
elevation: Dimens.zero,
actions: <Widget>[
@ -38,20 +37,19 @@ class CustomAppBar extends AppBar {
ScrollUpIconButton(
scrollController: scrollController,
),
if (isScreenReaderEnabled == false)
IconButton(
key: fontSizeIconButtonKey,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
IconButton(
key: fontSizeIconButtonKey,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
onPressed: onFontSizeTap,
),
onPressed: onFontSizeTap,
),
if (item is Story)
PinIconButton(
story: item,

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,12 +66,12 @@ 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) =>

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,
@ -70,7 +71,6 @@ class CommentTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Slidable(
enabled: context.isScreenReaderEnabled == false,
startActionPane: actionable
? ActionPane(
motion: const StretchMotion(),
@ -118,14 +118,6 @@ class CommentTile extends StatelessWidget {
: null,
child: InkWell(
onTap: () {
if (context.isScreenReaderEnabled) {
onMoreTapped?.call(
comment,
context.rect,
);
return;
}
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();

View File

@ -38,17 +38,15 @@ class ItemText extends StatelessWidget {
),
onTap: onTap,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
contextMenuBuilder: context.isScreenReaderEnabled
? null
: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
semanticsLabel: item.text,
);
} else {
@ -59,17 +57,15 @@ class ItemText extends StatelessWidget {
linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
onTap: onTap,
contextMenuBuilder: context.isScreenReaderEnabled
? null
: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
semanticsLabel: item.text,
);
}

View File

@ -7,13 +7,13 @@ 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.isOfflineReading,
@ -34,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
@ -141,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 = '',
@ -184,7 +172,7 @@ class _LinkPreviewState extends State<LinkPreview> {
description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri,
imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL,
onTap: widget.onTap,
titleTextStyle: widget.titleStyle,
bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines,

View File

@ -5,7 +5,9 @@ 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:hacki/utils/link_util.dart';
class LinkView extends StatelessWidget {
LinkView({
@ -41,7 +43,7 @@ class LinkView extends StatelessWidget {
final String description;
final String? imageUri;
final String? imagePath;
final void Function(String) onTap;
final VoidCallback onTap;
final TextStyle titleTextStyle;
final bool showMultiMedia;
final TextOverflow? bodyTextOverflow;
@ -176,17 +178,26 @@ class LinkView extends StatelessWidget {
titleStyle,
);
return InkWell(
onTap: () => onTap(url),
child: Row(
children: <Widget>[
if (showMultiMedia)
Padding(
padding: const EdgeInsets.only(
right: 8,
top: 5,
bottom: 5,
),
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,
@ -207,10 +218,13 @@ class LinkView extends StatelessWidget {
},
),
),
)
else
const SizedBox(width: Dimens.pt5),
SizedBox(
),
)
else
const SizedBox(width: Dimens.pt5),
TapDownWrapper(
onTap: onTap,
child: SizedBox(
height: layoutHeight,
width: layoutWidth - layoutHeight - 8,
child: Column(
@ -258,8 +272,8 @@ class LinkView extends StatelessWidget {
],
),
),
],
),
),
],
);
},
);

View File

@ -31,42 +31,39 @@ 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,
excludeSemantics: true,
child: TapDownWrapper(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
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,
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
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,
),
),
);

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.3+102
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