Compare commits

...

8 Commits

38 changed files with 500 additions and 272 deletions

View File

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

View File

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

View File

@ -41,7 +41,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async { await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) { if (loggedIn) {
final String? username = await _authRepository.username; 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( emit(
state.copyWith( state.copyWith(
@ -84,10 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (successful) { if (successful) {
final User user = await _storiesRepository.fetchUser(id: event.username); final User? user = await _storiesRepository.fetchUser(id: event.username);
emit( emit(
state.copyWith( state.copyWith(
user: user, user: user ?? User.emptyWithId(event.username),
isLoggedIn: true, isLoggedIn: true,
status: AuthStatus.loaded, status: AuthStatus.loaded,
), ),

View File

@ -17,11 +17,13 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> { class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({ StoriesBloc({
required PreferenceCubit preferenceCubit, required PreferenceCubit preferenceCubit,
required FilterCubit filterCubit,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
PreferenceRepository? preferenceRepository, PreferenceRepository? preferenceRepository,
Logger? logger, Logger? logger,
}) : _preferenceCubit = preferenceCubit, }) : _preferenceCubit = preferenceCubit,
_filterCubit = filterCubit,
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
_storiesRepository = _storiesRepository =
@ -45,6 +47,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
} }
final PreferenceCubit _preferenceCubit; final PreferenceCubit _preferenceCubit;
final FilterCubit _filterCubit;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
@ -224,10 +227,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
Emitter<StoriesState> emit, Emitter<StoriesState> emit,
) async { ) async {
final bool hasRead = await _preferenceRepository.hasRead(event.story.id); final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) =>
event.story.title.toLowerCase().contains(keyword) ||
event.story.text.toLowerCase().contains(keyword),
);
emit( emit(
state.copyWithStoryAdded( state.copyWithStoryAdded(
type: event.type, type: event.type,
story: event.story, story: event.story.copyWith(hidden: hidden),
hasRead: hasRead, hasRead: hasRead,
), ),
); );

View File

@ -7,7 +7,7 @@ abstract class Constants {
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md'; 'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
static const String hackerNewsLogoLink = static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png'; '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 githubLink = 'https://github.com/Livinglist/Hacki';
static const String appStoreLink = static const String appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review'; '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/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart'; import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
@ -20,18 +21,19 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> { class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({ CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache, required CollapseCache collapseCache,
CommentCache? commentCache, CommentCache? commentCache,
OfflineRepository? offlineRepository, OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository, StoriesRepository? storiesRepository,
SembastRepository? sembastRepository, SembastRepository? sembastRepository,
Logger? logger, Logger? logger,
required bool isScreenReaderEnabled,
required bool isOfflineReading, required bool isOfflineReading,
required Item item, required Item item,
required FetchMode defaultFetchMode, required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder, required CommentsOrder defaultCommentsOrder,
}) : _collapseCache = collapseCache, }) : _filterCubit = filterCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(), _commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository = _offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(), offlineRepository ?? locator.get<OfflineRepository>(),
@ -40,7 +42,6 @@ class CommentsCubit extends Cubit<CommentsState> {
_sembastRepository = _sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(), sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(), _logger = logger ?? locator.get<Logger>(),
_isScreenReaderEnabled = isScreenReaderEnabled,
super( super(
CommentsState.init( CommentsState.init(
isOfflineReading: isOfflineReading, isOfflineReading: isOfflineReading,
@ -50,13 +51,13 @@ class CommentsCubit extends Cubit<CommentsState> {
), ),
); );
final FilterCubit _filterCubit;
final CollapseCache _collapseCache; final CollapseCache _collapseCache;
final CommentCache _commentCache; final CommentCache _commentCache;
final OfflineRepository _offlineRepository; final OfflineRepository _offlineRepository;
final StoriesRepository _storiesRepository; final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository; final SembastRepository _sembastRepository;
final Logger _logger; final Logger _logger;
final bool _isScreenReaderEnabled;
/// The [StreamSubscription] for stream (both lazy or eager) /// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story. /// fetching comments posted directly to the story.
@ -351,17 +352,17 @@ class CommentsCubit extends Cubit<CommentsState> {
_commentCache.cacheComment(comment); _commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment); _sembastRepository.cacheComment(comment);
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),
);
final List<Comment> updatedComments = <Comment>[ final List<Comment> updatedComments = <Comment>[
...state.comments, ...state.comments,
comment comment.copyWith(hidden: hidden),
]; ];
emit(state.copyWith(comments: updatedComments)); emit(state.copyWith(comments: updatedComments));
if (state.fetchMode == FetchMode.eager) { if (state.fetchMode == FetchMode.eager) {
/// If screen reader is on, fetch all the comments without paging.
if (_isScreenReaderEnabled) return;
if (updatedComments.length >= if (updatedComments.length >=
_pageSize + _pageSize * state.currentPage && _pageSize + _pageSize * state.currentPage &&
updatedComments.length <= updatedComments.length <=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,6 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
extension ContextExtension on BuildContext { extension ContextExtension on BuildContext {
bool get isScreenReaderEnabled => MediaQuery.of(this).accessibleNavigation;
T? tryRead<T>() { T? tryRead<T>() {
try { try {
return read<T>(); return read<T>();
@ -21,8 +19,6 @@ extension ContextExtension on BuildContext {
VoidCallback? action, VoidCallback? action,
String? label, String? label,
}) { }) {
if (isScreenReaderEnabled) return;
ScaffoldMessenger.of(this).showSnackBar( ScaffoldMessenger.of(this).showSnackBar(
SnackBar( SnackBar(
backgroundColor: Palette.deepOrange, backgroundColor: Palette.deepOrange,

View File

@ -58,10 +58,12 @@ extension StateExtension on State {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (BuildContext context) { builder: (BuildContext context) {
return MorePopupMenu( return SafeArea(
child: MorePopupMenu(
item: item, item: item,
isBlocked: isBlocked, isBlocked: isBlocked,
onLoginTapped: onLoginTapped, onLoginTapped: onLoginTapped,
),
); );
}, },
).then((MenuAction? action) { ).then((MenuAction? action) {
@ -106,11 +108,12 @@ extension StateExtension on State {
linkToShare = await showModalBottomSheet<String>( linkToShare = await showModalBottomSheet<String>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Container( return SafeArea(
height: 140, child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
ListTile( ListTile(
onTap: () => Navigator.pop(context, item.url), onTap: () => Navigator.pop(context, item.url),
@ -126,6 +129,7 @@ extension StateExtension on State {
], ],
), ),
), ),
),
); );
}, },
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
static final List<Preference<dynamic>> allPreferences = static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>( UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[ <Preference<dynamic>>[
// Order of these first four preferences does not matter. // Order of these preferences does not matter.
FetchModePreference(), FetchModePreference(),
CommentsOrderPreference(), CommentsOrderPreference(),
FontPreference(), FontPreference(),
@ -31,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(), const NotificationModePreference(),
const SwipeGesturePreference(), const SwipeGesturePreference(),
const CollapseModePreference(), const CollapseModePreference(),
const NavigationModePreference(),
const ReaderModePreference(), const ReaderModePreference(),
const MarkReadStoriesModePreference(), const MarkReadStoriesModePreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
@ -54,7 +53,6 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true; const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false; const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true; const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValue = false;
const bool _eyeCandyModeDefaultValue = false; const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false; const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true; const bool _readerModeDefaultValue = true;
@ -189,29 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.'''; 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 { class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val}) const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue); : super(val: val ?? _readerModeDefaultValue);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,13 +23,11 @@ class ItemScreenArgs extends Equatable {
required this.item, required this.item,
this.onlyShowTargetComment = false, this.onlyShowTargetComment = false,
this.useCommentCache = false, this.useCommentCache = false,
this.isScreenReaderEnabled = false,
this.targetComments, this.targetComments,
}); });
final Item item; final Item item;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool isScreenReaderEnabled;
final List<Comment>? targetComments; final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't /// 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?>[ List<Object?> get props => <Object?>[
item, item,
onlyShowTargetComment, onlyShowTargetComment,
isScreenReaderEnabled,
targetComments, targetComments,
useCommentCache, useCommentCache,
]; ];
@ -67,6 +64,7 @@ class ItemScreen extends StatefulWidget {
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
isOfflineReading: isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading, context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
@ -75,7 +73,6 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.fetchMode, context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder: defaultCommentsOrder:
context.read<PreferenceCubit>().state.order, context.read<PreferenceCubit>().state.order,
isScreenReaderEnabled: args.isScreenReaderEnabled,
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments, targetAncestors: args.targetComments,
@ -110,6 +107,7 @@ class ItemScreen extends StatefulWidget {
providers: <BlocProvider<dynamic>>[ providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>( BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit( create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
isOfflineReading: isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading, context.read<StoriesBloc>().state.isOfflineReading,
item: args.item, item: args.item,
@ -118,7 +116,6 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.fetchMode, context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder: defaultCommentsOrder:
context.read<PreferenceCubit>().state.order, context.read<PreferenceCubit>().state.order,
isScreenReaderEnabled: args.isScreenReaderEnabled,
)..init( )..init(
onlyShowTargetComment: args.onlyShowTargetComment, onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments, targetAncestors: args.targetComments,
@ -178,7 +175,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance SchedulerBinding.instance
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
if (context.isScreenReaderEnabled == false) {
FeatureDiscovery.discoverFeatures( FeatureDiscovery.discoverFeatures(
context, context,
<String>{ <String>{
@ -187,7 +183,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
Constants.featureOpenStoryInWebView, Constants.featureOpenStoryInWebView,
}, },
); );
}
}) })
..addPostFrameCallback((_) { ..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context); final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -325,8 +320,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<SplitViewCubit>().zoom, context.read<SplitViewCubit>().zoom,
onFontSizeTap: onFontSizeTapped, onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey, fontSizeIconButtonKey: fontSizeIconButtonKey,
isScreenReaderEnabled:
context.isScreenReaderEnabled,
), ),
); );
}, },
@ -362,7 +355,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
scrollController: scrollController, scrollController: scrollController,
onFontSizeTap: onFontSizeTapped, onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey, fontSizeIconButtonKey: fontSizeIconButtonKey,
isScreenReaderEnabled: context.isScreenReaderEnabled,
), ),
body: MainView( body: MainView(
scrollController: scrollController, scrollController: scrollController,
@ -444,18 +436,17 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
} }
void onRightMoreTapped(Comment comment) { void onRightMoreTapped(Comment comment) {
const double bottomSheetHeight = 140;
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Container( return SafeArea(
height: bottomSheetHeight, child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
ListTile( ListTile(
leading: const Icon(Icons.av_timer), leading: const Icon(Icons.av_timer),
@ -485,6 +476,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
], ],
), ),
), ),
),
); );
}, },
); );

View File

@ -16,7 +16,6 @@ class CustomAppBar extends AppBar {
bool splitViewEnabled = false, bool splitViewEnabled = false,
VoidCallback? onZoomTap, VoidCallback? onZoomTap,
bool? expanded, bool? expanded,
bool isScreenReaderEnabled = false,
}) : super( }) : super(
elevation: Dimens.zero, elevation: Dimens.zero,
actions: <Widget>[ actions: <Widget>[
@ -38,7 +37,6 @@ class CustomAppBar extends AppBar {
ScrollUpIconButton( ScrollUpIconButton(
scrollController: scrollController, scrollController: scrollController,
), ),
if (isScreenReaderEnabled == false)
IconButton( IconButton(
key: fontSizeIconButtonKey, key: fontSizeIconButtonKey,
icon: Text( icon: Text(

View File

@ -23,9 +23,6 @@ class MorePopupMenu extends StatelessWidget {
final bool isBlocked; final bool isBlocked;
final VoidCallback onLoginTapped; final VoidCallback onLoginTapped;
static const double _storySheetHeight = 500;
static const double _commentSheetHeight = 480;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<VoteCubit>( return BlocProvider<VoteCubit>(
@ -69,12 +66,12 @@ class MorePopupMenu extends StatelessWidget {
builder: (BuildContext context, VoteState voteState) { builder: (BuildContext context, VoteState voteState) {
final bool upvoted = voteState.vote == Vote.up; final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down; final bool downvoted = voteState.vote == Vote.down;
return Container( return ColoredBox(
height: item is Comment ? _commentSheetHeight : _storySheetHeight,
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
BlocProvider<UserCubit>( BlocProvider<UserCubit>(
create: (BuildContext context) => create: (BuildContext context) =>

View File

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

View File

@ -43,6 +43,7 @@ class CommentTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (comment.hidden) return const SizedBox.shrink();
return BlocProvider<CollapseCubit>( return BlocProvider<CollapseCubit>(
key: ValueKey<String>('${comment.id}-BlocProvider'), key: ValueKey<String>('${comment.id}-BlocProvider'),
lazy: false, lazy: false,
@ -70,7 +71,6 @@ class CommentTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Slidable( Slidable(
enabled: context.isScreenReaderEnabled == false,
startActionPane: actionable startActionPane: actionable
? ActionPane( ? ActionPane(
motion: const StretchMotion(), motion: const StretchMotion(),
@ -118,14 +118,6 @@ class CommentTile extends StatelessWidget {
: null, : null,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (context.isScreenReaderEnabled) {
onMoreTapped?.call(
comment,
context.rect,
);
return;
}
if (actionable) { if (actionable) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse(); context.read<CollapseCubit>().collapse();

View File

@ -38,9 +38,7 @@ class ItemText extends StatelessWidget {
), ),
onTap: onTap, onTap: onTap,
textScaleFactor: MediaQuery.of(context).textScaleFactor, textScaleFactor: MediaQuery.of(context).textScaleFactor,
contextMenuBuilder: context.isScreenReaderEnabled contextMenuBuilder: (
? null
: (
BuildContext context, BuildContext context,
EditableTextState editableTextState, EditableTextState editableTextState,
) => ) =>
@ -59,9 +57,7 @@ class ItemText extends StatelessWidget {
linkStyle: linkStyle, linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url), onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
onTap: onTap, onTap: onTap,
contextMenuBuilder: context.isScreenReaderEnabled contextMenuBuilder: (
? null
: (
BuildContext context, BuildContext context,
EditableTextState editableTextState, EditableTextState editableTextState,
) => ) =>

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/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkPreview extends StatefulWidget { class LinkPreview extends StatefulWidget {
const LinkPreview({ const LinkPreview({
super.key, super.key,
required this.link, required this.link,
required this.story, required this.story,
required this.onTap,
required this.showMetadata, required this.showMetadata,
required this.showUrl, required this.showUrl,
required this.isOfflineReading, required this.isOfflineReading,
@ -34,6 +34,7 @@ class LinkPreview extends StatefulWidget {
}); });
final Story story; final Story story;
final VoidCallback onTap;
/// Web address (Url that need to be parsed) /// Web address (Url that need to be parsed)
/// For IOS & Web, only HTTP and HTTPS are support /// 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( Widget _buildLinkContainer(
double height, { double height, {
String? title = '', String? title = '',
@ -184,7 +172,7 @@ class _LinkPreviewState extends State<LinkPreview> {
description: desc ?? title ?? 'no comment yet.', description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri, imageUri: imageUri,
imagePath: Constants.hackerNewsLogoPath, imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL, onTap: widget.onTap,
titleTextStyle: widget.titleStyle, titleTextStyle: widget.titleStyle,
bodyTextOverflow: widget.bodyTextOverflow, bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines, bodyMaxLines: widget.bodyMaxLines,

View File

@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/models/models.dart'; import 'package:hacki/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/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
class LinkView extends StatelessWidget { class LinkView extends StatelessWidget {
LinkView({ LinkView({
@ -41,7 +43,7 @@ class LinkView extends StatelessWidget {
final String description; final String description;
final String? imageUri; final String? imageUri;
final String? imagePath; final String? imagePath;
final void Function(String) onTap; final VoidCallback onTap;
final TextStyle titleTextStyle; final TextStyle titleTextStyle;
final bool showMultiMedia; final bool showMultiMedia;
final TextOverflow? bodyTextOverflow; final TextOverflow? bodyTextOverflow;
@ -176,9 +178,7 @@ class LinkView extends StatelessWidget {
titleStyle, titleStyle,
); );
return InkWell( return Row(
onTap: () => onTap(url),
child: Row(
children: <Widget>[ children: <Widget>[
if (showMultiMedia) if (showMultiMedia)
Padding( Padding(
@ -187,6 +187,17 @@ class LinkView extends StatelessWidget {
top: 5, top: 5,
bottom: 5, bottom: 5,
), ),
child: TapDownWrapper(
onTap: () {
if (url.isNotEmpty) {
LinkUtil.launch(
url,
useHackiForHnLink: false,
);
} else {
onTap();
}
},
child: SizedBox( child: SizedBox(
height: layoutHeight, height: layoutHeight,
width: layoutHeight, width: layoutHeight,
@ -207,10 +218,13 @@ class LinkView extends StatelessWidget {
}, },
), ),
), ),
),
) )
else else
const SizedBox(width: Dimens.pt5), const SizedBox(width: Dimens.pt5),
SizedBox( TapDownWrapper(
onTap: onTap,
child: SizedBox(
height: layoutHeight, height: layoutHeight,
width: layoutWidth - layoutHeight - 8, width: layoutWidth - layoutHeight - 8,
child: Column( child: Column(
@ -258,8 +272,8 @@ class LinkView extends StatelessWidget {
], ],
), ),
), ),
],
), ),
],
); );
}, },
); );

View File

@ -31,18 +31,16 @@ class StoryTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (story.hidden) return const SizedBox.shrink();
if (showWebPreview) { if (showWebPreview) {
final double height = context.storyTileHeight; final double height = context.storyTileHeight;
return Semantics( return Semantics(
label: story.screenReaderLabel, label: story.screenReaderLabel,
excludeSemantics: true, excludeSemantics: true,
child: TapDownWrapper(
onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12, horizontal: Dimens.pt12,
), ),
child: AbsorbPointer(
child: LinkPreview( child: LinkPreview(
story: story, story: story,
link: story.url, link: story.url,
@ -65,8 +63,7 @@ class StoryTile extends StatelessWidget {
), ),
showMetadata: showMetadata, showMetadata: showMetadata,
showUrl: showUrl, showUrl: showUrl,
), onTap: onTap,
),
), ),
), ),
); );

View File

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

View File

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