Compare commits

...

10 Commits

35 changed files with 495 additions and 186 deletions

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,6 +21,7 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
@ -30,7 +32,8 @@ class CommentsCubit extends Cubit<CommentsState> {
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>(),
@ -48,6 +51,7 @@ class CommentsCubit extends Cubit<CommentsState> {
),
);
final FilterCubit _filterCubit;
final CollapseCache _collapseCache;
final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
@ -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

@ -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

@ -108,11 +108,12 @@ extension StateExtension on State {
linkToShare = await showModalBottomSheet<String>(
context: context,
builder: (BuildContext context) {
return Container(
height: 140,
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),
@ -128,6 +129,7 @@ extension StateExtension on State {
],
),
),
),
);
},
);

View File

@ -21,6 +21,7 @@ import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/custom_bloc_observer.dart';
import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/theme_util.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
@ -123,14 +124,6 @@ Future<void> main({bool testing = false}) async {
systemNavigationBarDividerColor: Palette.transparent,
),
);
} else {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent,
),
);
}
await SystemChrome.setEnabledSystemUIMode(
@ -147,7 +140,11 @@ Future<void> main({bool testing = false}) async {
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
);
// ignore: prefer_asserts_with_message
assert(() {
Bloc.observer = CustomBlocObserver();
return true;
}());
HydratedBloc.storage = storage;
@ -183,9 +180,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>(
@ -271,6 +273,10 @@ class HackiApp extends StatelessWidget {
AsyncSnapshot<AdaptiveThemeMode?> snapshot,
) {
final AdaptiveThemeMode? mode = snapshot.data;
ThemeUtil.updateAndroidStatusBarSetting(
Theme.of(context).brightness,
mode,
);
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen:
(PreferenceState previous, PreferenceState current) =>

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

@ -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.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,
@ -245,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

@ -64,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,
@ -106,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,
@ -434,18 +436,17 @@ 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,
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),
@ -475,6 +476,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
],
),
),
),
);
},
);

View File

@ -23,9 +23,6 @@ class MorePopupMenu extends StatelessWidget {
final bool isBlocked;
final VoidCallback onLoginTapped;
static const double _storySheetHeight = 485;
static const double _commentSheetHeight = 470;
@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',
@ -366,22 +372,19 @@ class _SettingsState extends State<Settings> {
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.light,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setLight(),
onChanged: updateThemeSetting,
title: const Text('Light'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.dark,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setDark(),
onChanged: updateThemeSetting,
title: const Text('Dark'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.system,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setSystem(),
onChanged: updateThemeSetting,
title: const Text('System'),
),
],
@ -391,6 +394,24 @@ class _SettingsState extends State<Settings> {
);
}
void updateThemeSetting(AdaptiveThemeMode? val) {
switch (val) {
case AdaptiveThemeMode.light:
AdaptiveTheme.of(context).setLight();
break;
case AdaptiveThemeMode.dark:
AdaptiveTheme.of(context).setDark();
break;
case AdaptiveThemeMode.system:
case null:
AdaptiveTheme.of(context).setSystem();
break;
}
final Brightness brightness = Theme.of(context).brightness;
ThemeUtil.updateAndroidStatusBarSetting(brightness, val);
}
void showClearCacheDialog() {
showDialog<void>(
context: context,
@ -640,6 +661,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,
@ -182,7 +183,7 @@ class CommentTile extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt8,
right: Dimens.pt8,
right: Dimens.pt2,
top: Dimens.pt6,
bottom: Dimens.pt12,
),

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,9 +178,7 @@ class LinkView extends StatelessWidget {
titleStyle,
);
return InkWell(
onTap: () => onTap(url),
child: Row(
return Row(
children: <Widget>[
if (showMultiMedia)
Padding(
@ -187,6 +187,17 @@ class LinkView extends StatelessWidget {
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(
TapDownWrapper(
onTap: onTap,
child: SizedBox(
height: layoutHeight,
width: layoutWidth - layoutHeight - 8,
child: Column(
@ -258,8 +272,8 @@ class LinkView extends StatelessWidget {
],
),
),
],
),
],
);
},
);

View File

@ -31,18 +31,16 @@ 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: AbsorbPointer(
child: LinkPreview(
story: story,
link: story.url,
@ -65,8 +63,7 @@ class StoryTile extends StatelessWidget {
),
showMetadata: showMetadata,
showUrl: showUrl,
),
),
onTap: onTap,
),
),
);

66
lib/utils/theme_util.dart Normal file
View File

@ -0,0 +1,66 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart';
import 'package:hacki/styles/styles.dart';
abstract class ThemeUtil {
/// Temp fix for the issue:
/// https://github.com/flutter/flutter/issues/119465
static Future<void> updateAndroidStatusBarSetting(
Brightness brightness,
AdaptiveThemeMode? mode,
) async {
if (Platform.isAndroid == false) return;
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
final int sdk = androidInfo.version.sdkInt;
if (sdk > 28) return;
switch (mode) {
case AdaptiveThemeMode.light:
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.dark,
statusBarIconBrightness: Brightness.dark,
statusBarColor: Palette.transparent,
),
);
break;
case AdaptiveThemeMode.dark:
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.light,
statusBarColor: Palette.transparent,
),
);
break;
case AdaptiveThemeMode.system:
case null:
switch (brightness) {
case Brightness.light:
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.dark,
statusBarIconBrightness: Brightness.dark,
statusBarColor: Palette.transparent,
),
);
break;
case Brightness.dark:
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.light,
statusBarColor: Palette.transparent,
),
);
break;
}
break;
}
}
}

View File

@ -4,4 +4,5 @@ export 'link_util.dart';
export 'linkifier_util.dart';
export 'log_util.dart';
export 'service_exception.dart';
export 'theme_util.dart';
export 'throttle.dart';

View File

@ -1375,4 +1375,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.8"
flutter: ">=3.7.9"

View File

@ -1,11 +1,11 @@
name: hacki
description: A Hacker News reader.
version: 1.3.4+103
version: 1.4.3+107
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.8"
flutter: "3.7.9"
dependencies:
adaptive_theme: ^3.2.0