Compare commits

..

28 Commits

Author SHA1 Message Date
ed48d95375 fix comments repo. (#364) 2024-01-03 14:31:03 -08:00
1eaded5694 fix uncaught error. (#359) 2023-12-11 01:01:26 -08:00
70bb78afcb use InterceptorsWrapper for caching. (#358) 2023-12-10 15:29:40 -08:00
df2d2478d5 improve comment fetching. (#357) 2023-12-09 18:20:28 -08:00
d5ae60327d change fetch method based on network condition. (#356) 2023-12-09 10:03:22 -08:00
615a092c1e update fetching strategy. (#355) 2023-12-09 02:05:03 -08:00
5a7699d866 update hacker_news_web_repository.dart (#354) 2023-12-09 00:00:20 -08:00
56a9bab3f2 improve error handling. (#353) 2023-12-08 22:37:57 -08:00
e9bbf46b4f fix comment fetching. (#352) 2023-12-08 21:25:02 -08:00
10f503a6c0 add cache for story metadata. (#350) 2023-12-08 20:09:05 -08:00
582f3156b2 add dev option. (#349) 2023-12-08 17:21:30 -08:00
90fb45146f fix story repository. (#348) 2023-12-08 14:25:32 -08:00
c19c54e762 optimize comment fetching. (#347) 2023-12-08 13:35:52 -08:00
70e5a84b63 improve comment fetching. (#346) 2023-12-08 10:18:03 -08:00
3a51fa83f2 update story tile padding. (#344) 2023-12-08 01:12:49 -08:00
cb90751330 fix flickering image. (#343) 2023-12-07 23:24:43 -08:00
835ed7e841 use different comment fetching strategy. (#342) 2023-12-07 21:46:13 -08:00
125ccd2dd1 use isolate to fetch comments. (#341) 2023-12-05 21:04:40 -08:00
5b991c4287 update theme. (#340) 2023-12-03 17:30:34 -08:00
7dc3618afe update color. (#339) 2023-12-02 23:31:45 -08:00
eef4691814 update Info.plist (#338) 2023-12-02 20:58:39 -08:00
9f71701845 update story tile. (#336) 2023-12-02 04:46:06 -08:00
d27203b041 update Info.plist (#335) 2023-12-02 04:21:58 -08:00
4f280ec4c9 add ability to sync favorites from Hacker News. (#334) 2023-12-01 21:53:48 -08:00
72cb2737ca fix story tile. (#333) 2023-12-01 12:09:14 -08:00
215203bd16 remove error placeholder. (#332) 2023-12-01 11:27:16 -08:00
3e320faece update story title. (#331) 2023-12-01 09:56:19 -08:00
1049568246 bump Flutter version to 3.16.2 (#330) 2023-12-01 01:11:30 -08:00
136 changed files with 1791 additions and 1003 deletions

View File

@ -35,22 +35,20 @@ Features:
<p align="center">
<img width="200" alt="01" src="assets/screenshots/01.png">
<img width="200" alt="02" src="assets/screenshots/02.png">
<img width="200" alt="03" src="assets/screenshots/03.png">
<img width="200" alt="04" src="assets/screenshots/04.png">
<img width="200" alt="05" src="assets/screenshots/05.png">
<img width="200" alt="06" src="assets/screenshots/06.png">
<img width="200" alt="07" src="assets/screenshots/07.png">
<img width="200" alt="08" src="assets/screenshots/08.png">
<img width="200" alt="09" src="assets/screenshots/09.png">
<img width="200" alt="10" src="assets/screenshots/10.png">
<img width="200" alt="11" src="assets/screenshots/11.png">
<img width="200" alt="12" src="assets/screenshots/12.png">
<img width="400" alt="01" src="assets/screenshots/light-1.png">
<img width="400" alt="06" src="assets/screenshots/dark-1.png">
<img width="400" alt="02" src="assets/screenshots/light-2.png">
<img width="400" alt="07" src="assets/screenshots/dark-2.png">
<img width="400" alt="03" src="assets/screenshots/light-3.png">
<img width="400" alt="08" src="assets/screenshots/dark-3.png">
<img width="400" alt="04" src="assets/screenshots/light-4.png">
<img width="400" alt="09" src="assets/screenshots/dark-4.png">
<img width="400" alt="05" src="assets/screenshots/light-5.png">
<img width="400" alt="10" src="assets/screenshots/dark-5.png">
<img width="400" alt="ipad-01" src="assets/screenshots/ipad-01.png">
<img width="400" alt="ipad-02" src="assets/screenshots/ipad-02.png">
<img width="400" alt="ipad-03" src="assets/screenshots/ipad-03.png">
<img width="400" alt="ipad-04" src="assets/screenshots/ipad-04.png">
<img width="400" alt="ipad-01" src="assets/screenshots/tablet-light-1.png">
<img width="400" alt="ipad-02" src="assets/screenshots/tablet-dark-1.png">
<img width="400" alt="ipad-03" src="assets/screenshots/tablet-light-2.png">
<img width="400" alt="ipad-04" src="assets/screenshots/tablet-dark-2.png">
</p>

View File

@ -23,7 +23,8 @@
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"

Binary file not shown.

Binary file not shown.

BIN
assets/hacki-github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/tablet-hacki.xcf Normal file

Binary file not shown.

View File

@ -0,0 +1,4 @@
- RobotoSlab as default font.
- Material 3 design.
- Ability to sync favorites from your Hacker News account.
- Support for predictive back gesture.

View File

@ -0,0 +1,3 @@
- Return of true dark mode.
- Better comment fetching strategy.
- Minor UI fixes.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -114,6 +114,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.logout();
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
await _sembastRepository.deleteAll();
await _sembastRepository.deleteCachedComments();
}
}

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
@ -32,10 +33,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(const StoriesState.init()) {
on<LoadStories>(
onLoadStories,
transformer: concurrent(),
);
on<StoriesInitialize>(onInitialize);
on<StoriesRefresh>(onRefresh);
on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded);
on<StoryLoaded>(
onStoryLoaded,
transformer: sequential(),
);
on<StoryRead>(onStoryRead);
on<StoryUnread>(onStoryUnread);
on<StoriesLoaded>(onStoriesLoaded);
@ -88,14 +96,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
),
);
for (final StoryType type in StoryType.values) {
await loadStories(type: type, emit: emit);
add(LoadStories(type: type));
}
}
Future<void> loadStories({
required StoryType type,
required Emitter<StoriesState> emit,
}) async {
Future<void> onLoadStories(
LoadStories event,
Emitter<StoriesState> emit,
) async {
final StoryType type = event.type;
if (state.isOfflineReading) {
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
@ -121,13 +130,12 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.copyWithStoryIdsUpdated(type: type, to: ids)
.copyWithCurrentPageUpdated(type: type, to: 0),
);
_hackerNewsRepository
await _hackerNewsRepository
.fetchStoriesStream(ids: ids.sublist(0, state.currentPageSize))
.listen((Story story) {
add(StoryLoaded(story: story, type: type));
}).onDone(() {
add(StoriesLoaded(type: type));
});
}).asFuture<void>();
add(StoriesLoaded(type: type));
}
}
@ -153,7 +161,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
);
} else {
emit(state.copyWithRefreshed(type: event.type));
await loadStories(type: event.type, emit: emit);
add(LoadStories(type: event.type));
}
}

View File

@ -5,6 +5,15 @@ abstract class StoriesEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
class LoadStories extends StoriesEvent {
LoadStories({required this.type});
final StoryType type;
@override
List<Object?> get props => <Object?>[type];
}
class StoriesInitialize extends StoriesEvent {
@override
List<Object?> get props => <Object?>[];

View File

@ -73,7 +73,7 @@ abstract class RegExpConstants {
static const String number = '[0-9]+';
}
abstract class Durations {
abstract class AppDurations {
static const Duration ms100 = Duration(milliseconds: 100);
static const Duration ms200 = Duration(milliseconds: 200);
static const Duration ms300 = Duration(milliseconds: 300);
@ -83,4 +83,7 @@ abstract class Durations {
static const Duration oneSecond = Duration(seconds: 1);
static const Duration twoSeconds = Duration(seconds: 2);
static const Duration tenSeconds = Duration(seconds: 10);
static const Duration sec30 = Duration(seconds: 30);
static const Duration oneMinute = Duration(minutes: 1);
static const Duration twoMinutes = Duration(minutes: 2);
}

View File

@ -25,6 +25,7 @@ Future<void> setUpLocator() async {
)
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<HackerNewsRepository>(HackerNewsRepository())
..registerSingleton<HackerNewsWebRepository>(HackerNewsWebRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository())
..registerSingleton<AuthRepository>(AuthRepository())

View File

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
@ -25,6 +26,7 @@ part 'comments_state.dart';
class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required PreferenceCubit preferenceCubit,
required CollapseCache collapseCache,
required bool isOfflineReading,
required Item item,
@ -32,15 +34,22 @@ class CommentsCubit extends Cubit<CommentsState> {
required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
SembastRepository? sembastRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _filterCubit = filterCubit,
_preferenceCubit = preferenceCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
_offlineRepository =
offlineRepository ?? locator.get<OfflineRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(
CommentsState.init(
@ -52,10 +61,13 @@ class CommentsCubit extends Cubit<CommentsState> {
);
final FilterCubit _filterCubit;
final PreferenceCubit _preferenceCubit;
final CollapseCache _collapseCache;
final CommentCache _commentCache;
final OfflineRepository _offlineRepository;
final SembastRepository _sembastRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
final ItemScrollController itemScrollController = ItemScrollController();
@ -71,6 +83,30 @@ class CommentsCubit extends Cubit<CommentsState> {
final Map<int, StreamSubscription<Comment>> _streamSubscriptions =
<int, StreamSubscription<Comment>>{};
static const int _webFetchingCmtCountLowerLimit = 50;
Future<bool> get _shouldFetchFromWeb async {
final bool isOnWifi = await _isOnWifi;
if (isOnWifi) {
return switch (state.item) {
Story(descendants: final int descendants)
when descendants > _webFetchingCmtCountLowerLimit =>
true,
Comment(kids: final List<int> kids)
when kids.length > _webFetchingCmtCountLowerLimit =>
true,
_ => false,
};
} else {
return true;
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
@override
void emit(CommentsState state) {
if (!isClosed) {
@ -82,6 +118,8 @@ class CommentsCubit extends Cubit<CommentsState> {
bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetAncestors,
AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit(
@ -139,11 +177,49 @@ class CommentsCubit extends Cubit<CommentsState> {
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
case FetchMode.eager:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
switch (state.order) {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_streamSubscription?.cancel();
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache:
useCommentCache ? _commentCache.getComment : null,
);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
}
}
}
@ -154,7 +230,10 @@ class CommentsCubit extends Cubit<CommentsState> {
..onDone(_onDone);
}
Future<void> refresh() async {
Future<void> refresh({
required AppExceptionHandler? onError,
bool fetchFromWeb = true,
}) async {
emit(
state.copyWith(
status: CommentsStatus.inProgress,
@ -191,14 +270,47 @@ class CommentsCubit extends Cubit<CommentsState> {
final List<int> kids = _sortKids(updatedItem.kids);
late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) {
commentStream = _hackerNewsRepository.fetchCommentsStream(
ids: kids,
);
} else {
commentStream = _hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
switch (state.fetchMode) {
case FetchMode.lazy:
commentStream = _hackerNewsRepository.fetchCommentsStream(ids: kids);
case FetchMode.eager:
switch (state.order) {
case CommentsOrder.natural:
final bool shouldFetchFromWeb = await _shouldFetchFromWeb;
if (fetchFromWeb && shouldFetchFromWeb) {
_logger.d('fetching from web.');
commentStream = _hackerNewsWebRepository
.fetchCommentsStream(state.item)
.handleError((dynamic e) {
_logger.e(e);
switch (e.runtimeType) {
case RateLimitedException:
case RateLimitedWithFallbackException:
case PossibleParsingException:
if (_preferenceCubit.state.devModeEnabled) {
onError?.call(e as AppException);
}
/// If fetching from web failed, fetch using API instead.
refresh(onError: onError, fetchFromWeb: false);
default:
onError?.call(GenericException());
}
});
} else {
_logger.d('fetching from API.');
commentStream = _hackerNewsRepository
.fetchAllCommentsRecursivelyStream(ids: kids);
}
case CommentsOrder.oldestFirst:
case CommentsOrder.newestFirst:
commentStream =
_hackerNewsRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
}
}
_streamSubscription = commentStream
@ -369,7 +481,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: index,
alignment: alignment,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
}
@ -394,7 +506,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -421,7 +533,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -461,7 +573,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: Durations.ms400,
duration: AppDurations.ms400,
);
return;
}
@ -538,6 +650,10 @@ class CommentsCubit extends Cubit<CommentsState> {
_collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment);
if (state.isOfflineReading) {
_sembastRepository.cacheComment(comment);
}
// Hide comment that matches any of the filter keywords.
final bool hidden = _filterCubit.state.keywords.any(
(String keyword) => comment.text.toLowerCase().contains(keyword),

View File

@ -12,7 +12,7 @@ part 'edit_state.dart';
class EditCubit extends HydratedCubit<EditState> {
EditCubit({DraftCache? draftCache})
: _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: Durations.oneSecond),
_debouncer = Debouncer(delay: AppDurations.oneSecond),
super(const EditState.init());
final DraftCache _draftCache;

View File

@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'fav_state.dart';
@ -13,12 +18,17 @@ class FavCubit extends Cubit<FavState> {
AuthRepository? authRepository,
PreferenceRepository? preferenceRepository,
HackerNewsRepository? hackerNewsRepository,
HackerNewsWebRepository? hackerNewsWebRepository,
Logger? logger,
}) : _authBloc = authBloc,
_authRepository = authRepository ?? locator.get<AuthRepository>(),
_preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_hackerNewsRepository =
hackerNewsRepository ?? locator.get<HackerNewsRepository>(),
_hackerNewsWebRepository =
hackerNewsWebRepository ?? locator.get<HackerNewsWebRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(FavState.init()) {
init();
}
@ -27,43 +37,42 @@ class FavCubit extends Cubit<FavState> {
final AuthRepository _authRepository;
final PreferenceRepository _preferenceRepository;
final HackerNewsRepository _hackerNewsRepository;
final HackerNewsWebRepository _hackerNewsWebRepository;
final Logger _logger;
late final StreamSubscription<String>? _usernameSubscription;
static const int _pageSize = 20;
String? _username;
Future<void> init() async {
_authBloc.stream.listen((AuthState authState) {
if (authState.username != _username) {
_preferenceRepository
.favList(of: authState.username)
.then((List<int> favIds) {
_usernameSubscription = _authBloc.stream
.map((AuthState event) => event.username)
.distinct()
.listen((String username) {
_preferenceRepository.favList(of: username).then((List<int> favIds) {
emit(
state.copyWith(
favIds: favIds,
favItems: <Item>[],
currentPage: 0,
),
);
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit(
state.copyWith(
favIds: favIds,
favItems: <Item>[],
currentPage: 0,
status: Status.success,
),
);
_hackerNewsRepository
.fetchItemsStream(
ids: favIds.sublist(0, _pageSize.clamp(0, favIds.length)),
)
.listen(_onItemLoaded)
.onDone(() {
emit(
state.copyWith(
status: Status.success,
),
);
});
});
_username = authState.username;
}
});
});
}
Future<void> addFav(int id) async {
final String username = _authBloc.state.username;
if (state.favIds.contains(id)) return;
await _preferenceRepository.addFav(username: username, id: id);
@ -89,9 +98,9 @@ class FavCubit extends Cubit<FavState> {
}
void removeFav(int id) {
final String username = _authBloc.state.username;
_preferenceRepository.removeFav(username: username, id: id);
_preferenceRepository
..removeFav(username: username, id: id)
..removeFav(username: '', id: id);
emit(
state.copyWith(
@ -136,8 +145,6 @@ class FavCubit extends Cubit<FavState> {
}
void refresh() {
final String username = _authBloc.state.username;
emit(
state.copyWith(
status: Status.inProgress,
@ -167,6 +174,34 @@ class FavCubit extends Cubit<FavState> {
emit(FavState.init());
}
Future<void> merge({
required AppExceptionHandler onError,
required VoidCallback onSuccess,
}) async {
if (_authBloc.state.isLoggedIn) {
emit(state.copyWith(mergeStatus: Status.inProgress));
try {
final Iterable<int> ids = await _hackerNewsWebRepository.fetchFavorites(
of: _authBloc.state.username,
);
_logger.d('fetched ${ids.length} favorite items from HN.');
final List<int> combinedIds = <int>[...ids, ...state.favIds];
final LinkedHashSet<int> mergedIds =
LinkedHashSet<int>.from(combinedIds);
await _preferenceRepository.overwriteFav(
username: username,
ids: mergedIds,
);
emit(state.copyWith(mergeStatus: Status.success));
onSuccess();
refresh();
} on RateLimitedException catch (e) {
onError(e);
emit(state.copyWith(mergeStatus: Status.failure));
}
}
}
void _onItemLoaded(Item item) {
emit(
state.copyWith(
@ -174,4 +209,14 @@ class FavCubit extends Cubit<FavState> {
),
);
}
@override
Future<void> close() {
_usernameSubscription?.cancel();
return super.close();
}
}
extension on FavCubit {
String get username => _authBloc.state.username;
}

View File

@ -5,6 +5,7 @@ class FavState extends Equatable {
required this.favIds,
required this.favItems,
required this.status,
required this.mergeStatus,
required this.currentPage,
});
@ -12,23 +13,27 @@ class FavState extends Equatable {
: favIds = <int>[],
favItems = <Item>[],
status = Status.idle,
mergeStatus = Status.idle,
currentPage = 0;
final List<int> favIds;
final List<Item> favItems;
final Status status;
final Status mergeStatus;
final int currentPage;
FavState copyWith({
List<int>? favIds,
List<Item>? favItems,
Status? status,
Status? mergeStatus,
int? currentPage,
}) {
return FavState(
favIds: favIds ?? this.favIds,
favItems: favItems ?? this.favItems,
status: status ?? this.status,
mergeStatus: mergeStatus ?? this.mergeStatus,
currentPage: currentPage ?? this.currentPage,
);
}
@ -36,6 +41,7 @@ class FavState extends Equatable {
@override
List<Object?> get props => <Object?>[
status,
mergeStatus,
currentPage,
favIds,
favItems,

View File

@ -9,6 +9,7 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'notification_state.dart';
@ -19,6 +20,7 @@ class NotificationCubit extends Cubit<NotificationState> {
HackerNewsRepository? hackerNewsRepository,
PreferenceRepository? preferenceRepository,
SembastRepository? sembastRepository,
Logger? logger,
}) : _authBloc = authBloc,
_preferenceCubit = preferenceCubit,
_hackerNewsRepository =
@ -27,12 +29,16 @@ class NotificationCubit extends Cubit<NotificationState> {
preferenceRepository ?? locator.get<PreferenceRepository>(),
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(NotificationState.init()) {
_authBloc.stream.listen((AuthState authState) {
if (authState.isLoggedIn && authState.username != _username) {
_authBloc.stream
.map((AuthState event) => event.username)
.distinct()
.listen((String username) {
if (username.isNotEmpty) {
// Get the user setting.
if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(Durations.twoSeconds, init);
Future<void>.delayed(AppDurations.twoSeconds, init);
}
// Listen for setting changes in the future.
@ -44,9 +50,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_timer?.cancel();
}
});
_username = authState.username;
} else if (!authState.isLoggedIn) {
} else {
emit(NotificationState.init());
}
});
@ -57,7 +61,7 @@ class NotificationCubit extends Cubit<NotificationState> {
final HackerNewsRepository _hackerNewsRepository;
final PreferenceRepository _preferenceRepository;
final SembastRepository _sembastRepository;
String? _username;
final Logger _logger;
Timer? _timer;
static const Duration _refreshInterval = Duration(minutes: 5);
@ -74,6 +78,7 @@ class NotificationCubit extends Cubit<NotificationState> {
});
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
_logger.i('NotificationCubit: ${unreadIds.length} unread items.');
emit(state.copyWith(unreadCommentsIds: unreadIds));
});

View File

@ -70,14 +70,14 @@ class PreferenceState extends Equatable {
bool get customTabEnabled => _isOn<CustomTabPreference>();
bool get material3Enabled => _isOn<Material3Preference>();
bool get manualPaginationEnabled => _isOn<ManualPaginationPreference>();
bool get trueDarkModeEnabled => _isOn<TrueDarkModePreference>();
bool get hapticFeedbackEnabled => _isOn<HapticFeedbackPreference>();
bool get devModeEnabled => _isOn<DevMode>();
double get textScaleFactor =>
preferences.singleWhereType<TextScaleFactorPreference>().val;

View File

@ -20,7 +20,7 @@ extension ContextExtension on BuildContext {
}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Theme.of(this).primaryColor,
backgroundColor: Theme.of(this).colorScheme.primary,
content: Text(
content,
style: TextStyle(
@ -38,9 +38,19 @@ extension ContextExtension on BuildContext {
);
}
void showErrorSnackBar() => showSnackBar(
content: Constants.errorMessage,
);
void showErrorSnackBar([String? message]) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Theme.of(this).colorScheme.errorContainer,
content: Text(
message ?? Constants.errorMessage,
style: TextStyle(
color: Theme.of(this).colorScheme.onErrorContainer,
),
),
),
);
}
Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?;

View File

@ -27,7 +27,8 @@ mixin ItemActionMixin<T extends StatefulWidget> on State<T> {
);
}
void showErrorSnackBar() => context.showErrorSnackBar();
void showErrorSnackBar([String? message]) =>
context.showErrorSnackBar(message);
Future<void>? goToItemScreen({
required ItemScreenArgs args,

View File

@ -138,7 +138,7 @@ Future<void> main({bool testing = false}) async {
HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
VisibilityDetectorController.instance.updateInterval = AppDurations.ms200;
runApp(
HackiApp(
@ -240,12 +240,11 @@ class HackiApp extends StatelessWidget {
previous.appColor != current.appColor ||
previous.font != current.font ||
previous.textScaleFactor != current.textScaleFactor ||
previous.material3Enabled != current.material3Enabled ||
previous.trueDarkModeEnabled != current.trueDarkModeEnabled,
builder: (BuildContext context, PreferenceState state) {
return AdaptiveTheme(
key: ValueKey<String>(
'''${state.appColor}${state.font}${state.material3Enabled}${state.trueDarkModeEnabled}''',
'''${state.appColor}${state.font}${state.trueDarkModeEnabled}''',
),
light: ThemeData(
primaryColor: state.appColor,
@ -261,7 +260,6 @@ class HackiApp extends StatelessWidget {
primarySwatch: state.appColor,
brightness: Brightness.dark,
),
canvasColor: state.trueDarkModeEnabled ? Palette.black : null,
fontFamily: state.font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,
@ -285,73 +283,72 @@ class HackiApp extends StatelessWidget {
.platformDispatcher
.platformBrightness ==
Brightness.dark);
final ColorScheme colorScheme = ColorScheme.fromSeed(
brightness:
isDarkModeEnabled ? Brightness.dark : Brightness.light,
seedColor: state.appColor,
background: isDarkModeEnabled && state.trueDarkModeEnabled
? Palette.black
: null,
);
return FeatureDiscovery(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor: state.textScaleFactor == 1
? null
: state.textScaleFactor,
),
data: state.textScaleFactor == 1
? MediaQuery.of(context)
: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
state.textScaleFactor,
),
),
child: MaterialApp.router(
key: Key(state.appColor.hashCode.toString()),
title: 'Hacki',
debugShowCheckedModeBanner: false,
theme: (isDarkModeEnabled ? darkTheme : theme).copyWith(
useMaterial3: state.material3Enabled,
dividerTheme: state.material3Enabled
? DividerThemeData(
color: Palette.grey.withOpacity(0.2),
)
: null,
switchTheme: state.material3Enabled
? SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states
.contains(MaterialState.selected)) {
return null;
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
)
: null,
bottomSheetTheme: state.material3Enabled
? const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
)
: null,
inputDecorationTheme: state.material3Enabled
? InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
)
: null,
sliderTheme: state.material3Enabled
? SliderThemeData(
inactiveTrackColor:
state.appColor.shade200.withOpacity(0.5),
)
: null,
outlinedButtonTheme: state.material3Enabled
? OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),
),
),
)
: null,
theme: ThemeData(
colorScheme: colorScheme,
fontFamily: state.font.name,
dividerTheme: DividerThemeData(
color: Palette.grey.withOpacity(0.2),
),
switchTheme: SwitchThemeData(
trackColor: MaterialStateProperty.resolveWith(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return colorScheme.primary.withOpacity(0.6);
} else {
return Palette.grey.withOpacity(0.2);
}
},
),
),
bottomSheetTheme: const BottomSheetThemeData(
modalElevation: 8,
clipBehavior: Clip.hardEdge,
shadowColor: Palette.black,
),
inputDecorationTheme: InputDecorationTheme(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkModeEnabled
? Palette.white
: Palette.black,
),
),
),
sliderTheme: SliderThemeData(
inactiveTrackColor:
colorScheme.primary.withOpacity(0.5),
activeTrackColor: colorScheme.primary,
thumbColor: colorScheme.primary,
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: MaterialStateBorderSide.resolveWith(
(_) => const BorderSide(
color: Palette.grey,
),
),
),
),
),
routerConfig: router,
),

View File

@ -0,0 +1,32 @@
typedef AppExceptionHandler = void Function(AppException);
class AppException implements Exception {
AppException({
required this.message,
this.stackTrace,
});
final String? message;
final StackTrace? stackTrace;
}
class RateLimitedException extends AppException {
RateLimitedException() : super(message: 'Rate limited...');
}
class RateLimitedWithFallbackException extends AppException {
RateLimitedWithFallbackException()
: super(message: 'Rate limited, fetching from API instead...');
}
class PossibleParsingException extends AppException {
PossibleParsingException({
required this.itemId,
}) : super(message: 'Possible parsing failure...');
final int itemId;
}
class GenericException extends AppException {
GenericException() : super(message: 'Something went wrong...');
}

View File

@ -6,4 +6,7 @@ enum CommentsOrder {
const CommentsOrder(this.description);
final String description;
@override
String toString() => description;
}

View File

@ -0,0 +1,19 @@
import 'package:dio/dio.dart';
class CachedResponse<T> extends Response<T> {
CachedResponse({
required super.requestOptions,
super.data,
super.statusCode,
}) : setDateTime = DateTime.now();
factory CachedResponse.fromResponse(Response<T> response) {
return CachedResponse<T>(
requestOptions: response.requestOptions,
data: response.data,
statusCode: response.statusCode,
);
}
final DateTime setDateTime;
}

View File

@ -2,7 +2,7 @@ enum DiscoverableFeature {
addStoryToFavList(
featureId: 'add_story_to_fav_list',
title: 'Fav a Story',
description: '''Add it to your favorites''',
description: '''Add it to your favorites.''',
),
openStoryInWebView(
featureId: 'open_story_in_web_view',

View File

@ -5,4 +5,7 @@ enum FetchMode {
const FetchMode(this.description);
final String description;
@override
String toString() => description;
}

View File

@ -3,26 +3,11 @@ enum Font {
robotoSlab('Roboto Slab', isSerif: true),
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono'),
notoSerif('Noto Serif', isSerif: true);
notoSerif('Noto Serif', isSerif: true),
exo2('Exo 2');
const Font(this.uiLabel, {this.isSerif = false});
final String uiLabel;
final bool isSerif;
static Font fromString(String? val) {
switch (val) {
case 'robotoSlab':
return Font.robotoSlab;
case 'ubuntu':
return Font.ubuntu;
case 'ubuntuMono':
return Font.ubuntuMono;
case 'notoSerif':
return Font.notoSerif;
case 'roboto':
default:
return Font.roboto;
}
}
}

View File

@ -90,8 +90,13 @@ class Item extends Equatable {
final List<int> kids;
final List<int> parts;
String get timeAgo =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
String get timeAgo {
int time = this.time;
if (time < 9999999999) {
time = time * 1000;
}
return DateTime.fromMillisecondsSinceEpoch(time).toTimeAgoString();
}
bool get isPoll => type == 'poll';

View File

@ -1,3 +1,4 @@
export 'app_exception.dart';
export 'comments_order.dart';
export 'discoverable_feature.dart';
export 'export_destination.dart';

View File

@ -46,7 +46,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const HapticFeedbackPreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
const Material3Preference(),
const DevMode(),
],
);
@ -79,19 +79,40 @@ const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = false;
const bool _customTabModeDefaultValue = false;
const bool _material3ModeDefaultValue = false;
const bool _paginationModeDefaultValue = false;
const bool _devModeDefaultValue = false;
const double _textScaleFactorDefaultValue = 1;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _appColorDefaultValue = materialColors.indexOf(Palette.deepOrange);
final int _fontDefaultValue = Font.roboto.index;
final int _fontDefaultValue = Font.robotoSlab.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index;
class DevMode extends BooleanPreference {
const DevMode({bool? val}) : super(val: val ?? _devModeDefaultValue);
@override
DevMode copyWith({required bool? val}) {
return DevMode(val: val);
}
@override
String get key => 'devMode';
@override
String get title => 'Dev Mode';
@override
String get subtitle => '';
@override
bool get isDisplayable => false;
}
class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue);
@ -165,7 +186,7 @@ class AutoScrollModePreference extends BooleanPreference {
String get key => 'autoScrollMode';
@override
String get title => 'Auto-scroll on collapsing';
String get title => 'Auto-scroll on Collapsing';
@override
String get subtitle =>
@ -312,26 +333,6 @@ class ManualPaginationPreference extends BooleanPreference {
String get subtitle => '''so you can get stuff done.''';
}
class Material3Preference extends BooleanPreference {
const Material3Preference({bool? val})
: super(val: val ?? _material3ModeDefaultValue);
@override
Material3Preference copyWith({required bool? val}) {
return Material3Preference(val: val);
}
@override
String get key => 'material3Mode';
@override
String get title => 'Material 3';
@override
String get subtitle =>
'''experimental feature. Please open an issue on GitHub if you notice anything weird.''';
}
/// Whether or not to use Custom Tabs for launching URLs.
/// If false, default browser will be used.
///
@ -395,9 +396,6 @@ class HapticFeedbackPreference extends BooleanPreference {
@override
String get subtitle => '';
@override
bool get isDisplayable => Platform.isIOS;
}
class FetchModePreference extends IntPreference {

View File

@ -223,6 +223,9 @@ class HackerNewsRepository {
/// Fetch a list of [Comment] based on ids and return results
/// using a stream.
///
/// this function caches every comment fetched to [SembastRepository] so that
/// we don't need to parse the text again later.
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
@ -258,6 +261,9 @@ class HackerNewsRepository {
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
///
/// this function caches every comment fetched to [SembastRepository] so that
/// we don't need to parse the text again later.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,

View File

@ -0,0 +1,287 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
import 'package:html/dom.dart' hide Comment;
import 'package:html/parser.dart';
import 'package:html_unescape/html_unescape.dart';
/// For fetching anything that cannot be fetched through Hacker News API.
class HackerNewsWebRepository {
HackerNewsWebRepository({
Dio? dioWithCache,
Dio? dio,
}) : _dio = dio ?? Dio(),
_dioWithCache = dioWithCache ?? Dio()
..interceptors.addAll(
<Interceptor>[
if (kDebugMode) LoggerInterceptor(),
CacheInterceptor(),
],
);
final Dio _dioWithCache;
final Dio _dio;
static const Map<String, String> _headers = <String, String>{
'accept': '*/*',
'user-agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
};
static const String _favoritesBaseUrl =
'https://news.ycombinator.com/favorites?id=';
static const String _aThingSelector =
'#hnmain > tbody > tr:nth-child(3) > td > table > tbody > .athing';
Future<Iterable<int>> fetchFavorites({required String of}) async {
final bool isOnWifi = await _isOnWifi;
final String username = of;
final List<int> allIds = <int>[];
int page = 1;
const int maxPage = 2;
Future<Iterable<int>> fetchIds(int page, {bool isComment = false}) async {
try {
final Uri url = Uri.parse(
'''$_favoritesBaseUrl$username${isComment ? '&comments=t' : ''}&p=$page''',
);
final Response<String> response =
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(url);
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
final Document document = parse(response.data);
final List<Element> elements =
document.querySelectorAll(_aThingSelector);
final Iterable<int> parsedIds =
elements.map((Element e) => int.tryParse(e.id)).whereNotNull();
return parsedIds;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedException();
}
throw GenericException();
}
}
Iterable<int> ids;
while (page <= maxPage) {
ids = await fetchIds(page);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
page = 1;
while (page <= maxPage) {
ids = await fetchIds(page, isComment: true);
if (ids.isEmpty) {
break;
}
allIds.addAll(ids);
page++;
}
return allIds;
}
static const String _itemBaseUrl = 'https://news.ycombinator.com/item?id=';
static const String _athingComtrSelector =
'#hnmain > tbody > tr > td > table > tbody > .athing.comtr';
static const String _commentTextSelector =
'''td > table > tbody > tr > td.default > div.comment''';
static const String _commentHeadSelector =
'''td > table > tbody > tr > td.default > div > span > a''';
static const String _commentAgeSelector =
'''td > table > tbody > tr > td.default > div > span > span.age''';
static const String _commentIndentSelector =
'''td > table > tbody > tr > td.ind''';
Stream<Comment> fetchCommentsStream(Item item) async* {
final bool isOnWifi = await _isOnWifi;
final int itemId = item.id;
final int? descendants = item is Story ? item.descendants : null;
int parentTextCount = 0;
Future<Iterable<Element>> fetchElements(int page) async {
try {
final Uri url = Uri.parse('$_itemBaseUrl$itemId&p=$page');
final Options option = Options(
headers: _headers,
persistentConnection: true,
);
/// Be more conservative while user is on wifi.
final Response<String> response =
await (isOnWifi ? _dioWithCache : _dio).getUri<String>(
url,
options: option,
);
final String data = response.data ?? '';
if (page == 1) {
parentTextCount = 'parent'.allMatches(data).length;
}
final Document document = parse(data);
final List<Element> elements =
document.querySelectorAll(_athingComtrSelector);
return elements;
} on DioException catch (e) {
if (e.response?.statusCode == HttpStatus.forbidden) {
throw RateLimitedWithFallbackException();
}
throw GenericException();
}
}
if (descendants == 0 || item.kids.isEmpty) return;
final Set<int> fetchedCommentIds = <int>{};
int page = 1;
Iterable<Element> elements = await fetchElements(page);
final Map<int, int> indentToParentId = <int, int>{};
if (item is Story && item.descendants > 0 && elements.isEmpty) {
throw PossibleParsingException(itemId: itemId);
}
while (elements.isNotEmpty) {
for (final Element element in elements) {
/// Get comment id.
final String cmtIdString = element.attributes['id'] ?? '';
final int? cmtId = int.tryParse(cmtIdString);
/// Get comment text.
final Element? cmtTextElement =
element.querySelector(_commentTextSelector);
final String parsedText = await compute(
_parseCommentTextHtml,
cmtTextElement?.innerHtml ?? '',
);
/// Get comment author.
final Element? cmtHeadElement =
element.querySelector(_commentHeadSelector);
final String? cmtAuthor = cmtHeadElement?.text;
/// Get comment age.
final Element? cmtAgeElement =
element.querySelector(_commentAgeSelector);
final String? ageString = cmtAgeElement?.attributes['title'];
final int? timestamp = ageString == null
? null
: DateTime.parse(ageString)
.copyWith(isUtc: true)
.millisecondsSinceEpoch;
/// Get comment indent.
final Element? cmtIndentElement =
element.querySelector(_commentIndentSelector);
final String? indentString = cmtIndentElement?.attributes['indent'];
final int indent =
indentString == null ? 0 : (int.tryParse(indentString) ?? 0);
indentToParentId[indent] = cmtId ?? 0;
final int parentId = indentToParentId[indent - 1] ?? -1;
final Comment cmt = Comment(
id: cmtId ?? 0,
time: timestamp ?? 0,
parent: parentId,
score: 0,
by: cmtAuthor ?? '',
text: parsedText,
kids: const <int>[],
dead: false,
deleted: false,
hidden: false,
level: indent,
isFromCache: false,
);
/// Skip any comment with no valid id or timestamp.
if (cmt.id == 0 || timestamp == 0) {
continue;
}
/// Duplicate comment means we are done fetching all the comments.
if (fetchedCommentIds.contains(cmt.id)) return;
fetchedCommentIds.add(cmt.id);
yield cmt;
}
/// If we didn't successfully got any comment on first page,
/// and we are sure there are comments there based on the count of
/// 'parent' text, then this might be a parsing error and possibly is
/// caused by HN changing their HTML structure, therefore here we
/// throw an error so that we can fallback to use API instead.
if (page == 1 && parentTextCount > 0 && fetchedCommentIds.isEmpty) {
throw PossibleParsingException(itemId: itemId);
}
if (descendants != null && fetchedCommentIds.length >= descendants) {
return;
}
/// Due to rate limiting, we have a short break here.
await Future<void>.delayed(AppDurations.twoSeconds);
page++;
elements = await fetchElements(page);
}
}
static Future<bool> get _isOnWifi async {
final ConnectivityResult status = await Connectivity().checkConnectivity();
return status == ConnectivityResult.wifi;
}
static Future<String> _parseCommentTextHtml(String text) async {
return HtmlUnescape()
.convert(text)
.replaceAllMapped(
RegExp(
r'\<div class="reply"\>(.*?)\<\/div\>',
dotAll: true,
),
(Match match) => '',
)
.replaceAllMapped(
RegExp(
r'\<span class="(.*?)"\>(.*?)\<\/span\>',
dotAll: true,
),
(Match match) => '${match[2]}',
)
.replaceAllMapped(
RegExp(
r'\<p\>(.*?)\<\/p\>',
dotAll: true,
),
(Match match) => '\n\n${match[1]}',
)
.replaceAllMapped(
RegExp(r'\<a href=\"(.*?)\".*?\>.*?\<\/a\>'),
(Match match) => match[1] ?? '',
)
.replaceAllMapped(
RegExp(r'\<i\>(.*?)\<\/i\>'),
(Match match) => '*${match[1]}*',
)
.trim();
}
}

View File

@ -185,7 +185,9 @@ class OfflineRepository {
if (json == null) {
return null;
}
final Comment comment = Comment.fromJson(json.cast<String, dynamic>());
final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson);
return comment;
} catch (_) {
_logger.e(_);
@ -204,8 +206,9 @@ class OfflineRepository {
final Map<dynamic, dynamic>? json = await box.get(id.toString());
if (json != null) {
final Comment comment =
Comment.fromJson(json.cast<String, dynamic>(), level: level);
final Map<String, dynamic> typedJson = json.cast<String, dynamic>();
typedJson['fromCache'] = true;
final Comment comment = Comment.fromJson(typedJson, level: level);
yield comment;
yield* getCachedCommentsStream(ids: comment.kids, level: level + 1);

View File

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/auth_repository.dart';
import 'package:hacki/repositories/post_repository.dart';
import 'package:hacki/utils/service_exception.dart';
/// [PostableRepository] is solely for hosting functionalities shared between
/// [AuthRepository] and [PostRepository].
@ -40,7 +39,7 @@ class PostableRepository {
}
return true;
} on ServiceException {
} on AppException {
return false;
}
}
@ -65,7 +64,7 @@ class PostableRepository {
),
);
} on DioException catch (e) {
throw ServiceException(e.message);
throw AppException(message: e.message);
}
}

View File

@ -157,7 +157,6 @@ class PreferenceRepository {
((prefs.getStringList(_getFavKey('')) ?? <String>[])
..addAll(prefs.getStringList(_getFavKey(of)) ?? <String>[]))
.map(int.parse)
.toSet()
.toList();
return favList;
@ -175,7 +174,7 @@ class PreferenceRepository {
await _syncedPrefs.setStringList(
key: key,
val: favList.map((int e) => e.toString()).toSet().toList(),
val: favList.map((int e) => e.toString()).toList(),
);
} else {
final SharedPreferences prefs = await _prefs;
@ -186,7 +185,30 @@ class PreferenceRepository {
await prefs.setStringList(
key,
favList.map((int e) => e.toString()).toSet().toList(),
favList.map((int e) => e.toString()).toList(),
);
}
}
Future<void> overwriteFav({
required String username,
required Iterable<int> ids,
}) async {
final String key = _getFavKey(username);
final List<String> favList =
ids.map((int e) => e.toString()).toList(growable: false);
if (Platform.isIOS) {
await _syncedPrefs.setStringList(
key: key,
val: favList,
);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(
key,
favList,
);
}
}

View File

@ -1,5 +1,6 @@
export 'auth_repository.dart';
export 'hacker_news_repository.dart';
export 'hacker_news_web_repository.dart';
export 'offline_repository.dart';
export 'post_repository.dart';
export 'preference_repository.dart';

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
@ -12,23 +14,34 @@ import 'package:sembast/sembast_io.dart';
/// documents directory assigned by host system which you can retrieve
/// by calling [getApplicationDocumentsDirectory].
class SembastRepository {
SembastRepository({Database? database}) {
SembastRepository({
Database? database,
Database? cache,
}) {
if (database == null) {
initializeDatabase();
} else {
_database = database;
}
if (cache == null) {
initializeCache();
} else {
_cache = cache;
}
}
Database? _database;
Database? _cache;
List<int>? _idsOfCommentsRepliedToMe;
static const String _cachedCommentsKey = 'cachedComments';
static const String _commentsKey = 'comments';
static const String _idsOfCommentsRepliedToMeKey = 'idsOfCommentsRepliedToMe';
static const String _metadataCacheKey = 'metadata';
Future<Database> initializeDatabase() async {
final Directory dir = await getApplicationDocumentsDirectory();
final Directory dir = await getApplicationCacheDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
final DatabaseFactory dbFactory = databaseFactoryIo;
@ -37,6 +50,16 @@ class SembastRepository {
return db;
}
Future<Database> initializeCache() async {
final Directory dir = await getTemporaryDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki_cache.db');
final DatabaseFactory dbFactory = databaseFactoryIo;
final Database db = await dbFactory.openDatabase(dbPath);
_cache = db;
return db;
}
//#region Cached comments for time machine feature.
Future<Map<String, Object?>> cacheComment(Comment comment) async {
final Database db = _database ?? await initializeDatabase();
@ -177,10 +200,50 @@ class SembastRepository {
//#endregion
Future<FileSystemEntity> deleteAll() async {
//#region
Future<void> cacheMetadata({
required String key,
required WebInfo info,
}) async {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
return db.transaction((Transaction txn) async {
await store.record(key).put(txn, info.toJson());
});
}
Future<WebInfo?> getCachedMetadata({
required String key,
}) async {
final Database db = _cache ?? await initializeCache();
final StoreRef<String, Map<String, Object?>> store =
stringMapStoreFactory.store(_metadataCacheKey);
final RecordSnapshot<String, Map<String, Object?>>? snapshot =
await store.record(key).getSnapshot(db);
if (snapshot != null) {
final WebInfo info = WebInfo.fromJson(snapshot.value);
return info;
} else {
return null;
}
}
//#endregion
Future<FileSystemEntity> deleteCachedComments() async {
final Directory dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true);
final String dbPath = join(dir.path, 'hacki.db');
return File(dbPath).delete();
}
Future<FileSystemEntity> deleteCachedMetadata() async {
final Directory tempDir = await getTemporaryDirectory();
await tempDir.create(recursive: true);
final String cachePath = join(tempDir.path, 'hacki_cache.db');
return File(cachePath).delete();
}
}

View File

@ -49,9 +49,9 @@ class _HomeScreenState extends State<HomeScreen>
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('Resetting comments in CommentCache');
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
Durations.ms500,
AppDurations.ms500,
locator.get<CommentCache>().resetComments,
);
}
@ -141,15 +141,8 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
CustomTabBar(
tabController: tabController,
),
],
),

View File

@ -45,7 +45,8 @@ class PinnedStories extends StatelessWidget {
],
),
child: ColoredBox(
color: Theme.of(context).primaryColor.withOpacity(0.2),
color:
Theme.of(context).colorScheme.primary.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
@ -61,7 +62,7 @@ class PinnedStories extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Theme.of(context).primaryColor.withOpacity(0.8),
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
),
),
],

View File

@ -37,7 +37,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero,
bottom: Dimens.zero,
width: homeScreenWidth,
duration: Durations.ms300,
duration: AppDurations.ms300,
curve: Curves.elasticOut,
child: homeScreen,
),
@ -53,7 +53,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: Durations.ms300,
duration: AppDurations.ms300,
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),

View File

@ -69,6 +69,7 @@ class ItemScreen extends StatefulWidget {
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
preferenceCubit: context.read<PreferenceCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
@ -79,6 +80,8 @@ class ItemScreen extends StatefulWidget {
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
),
),
],
@ -92,15 +95,15 @@ class ItemScreen extends StatefulWidget {
}
static Widget tablet(BuildContext context, ItemScreenArgs args) {
return WillPopScope(
onWillPop: () async {
return PopScope(
canPop: () {
if (context.read<SplitViewCubit>().state.expanded) {
context.read<SplitViewCubit>().zoom();
return false;
} else {
return true;
}
},
}(),
child: RepositoryProvider<CollapseCache>(
create: (_) => CollapseCache(),
lazy: false,
@ -110,6 +113,7 @@ class ItemScreen extends StatefulWidget {
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
preferenceCubit: context.read<PreferenceCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
@ -121,6 +125,8 @@ class ItemScreen extends StatefulWidget {
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
),
),
],
@ -160,9 +166,9 @@ class _ItemScreenState extends State<ItemScreen>
final GlobalKey fontSizeIconButtonKey = GlobalKey();
StreamSubscription<double>? scrollOffsetSubscription;
static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds;
static const Duration _storyLinkTapThrottleDelay = AppDurations.twoSeconds;
static const Duration _featureDiscoveryDismissThrottleDelay =
Durations.oneSecond;
AppDurations.oneSecond;
@override
void didPop() {
@ -300,6 +306,7 @@ class _ItemScreenState extends State<ItemScreen>
left: Dimens.zero,
right: Dimens.zero,
child: CustomAppBar(
context: context,
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
@ -342,6 +349,7 @@ class _ItemScreenState extends State<ItemScreen>
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
context: context,
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
foregroundColor: Theme.of(context).iconTheme.color,
@ -413,7 +421,7 @@ class _ItemScreenState extends State<ItemScreen>
fontSize: fontSize.fontSize,
color:
context.read<PreferenceCubit>().state.fontSize == fontSize
? Theme.of(context).primaryColor
? Theme.of(context).colorScheme.primary
: null,
),
),

View File

@ -7,6 +7,7 @@ import 'package:hacki/utils/utils.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
required BuildContext context,
required Item item,
required super.backgroundColor,
required super.foregroundColor,
@ -44,8 +45,9 @@ class CustomAppBar extends AppBar {
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textScaleFactor: 1,
textScaler: TextScaler.noScaling,
),
onPressed: onFontSizeTap,
),

View File

@ -26,7 +26,7 @@ class CustomFloatingActionButton extends StatelessWidget {
bottom: Dimens.replyBoxCollapsedHeight,
)
: EdgeInsets.zero,
duration: Durations.ms200,
duration: AppDurations.ms200,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[

View File

@ -30,8 +30,8 @@ class FavIconButton extends StatelessWidget {
child: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav
? Theme.of(context).primaryColor
: Theme.of(context).iconTheme.color,
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
onPressed: () {

View File

@ -2,6 +2,7 @@ import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/comments/comments_cubit.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -65,9 +66,11 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
super.initState();
scrollController.addListener(onScroll);
textEditingController.text = widget.commentsCubit.state.inThreadSearchQuery;
if (textEditingController.text.isEmpty) {
focusNode.requestFocus();
}
Future<void>.delayed(AppDurations.ms300, () {
if (textEditingController.text.isEmpty) {
focusNode.requestFocus();
}
});
}
@override
@ -110,14 +113,14 @@ class _InThreadSearchViewState extends State<_InThreadSearchView> {
child: TextField(
controller: textEditingController,
focusNode: focusNode,
cursorColor: Theme.of(context).primaryColor,
cursorColor: Theme.of(context).colorScheme.primary,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search in this thread',
suffixText: '${state.matchedComments.length} results',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
),
),
),

View File

@ -40,7 +40,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
width: Dimens.pt36,
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
),
),
)
@ -51,13 +51,14 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
),
child: TextField(
controller: usernameController,
cursorColor: Theme.of(context).primaryColor,
cursorColor: Theme.of(context).colorScheme.primary,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).primaryColor),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
),
@ -71,14 +72,15 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
),
child: TextField(
controller: passwordController,
cursorColor: Theme.of(context).primaryColor,
cursorColor: Theme.of(context).colorScheme.primary,
obscureText: true,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Password',
focusedBorder: UnderlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).primaryColor),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
),
@ -110,7 +112,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
? Icons.check_box
: Icons.check_box_outline_blank,
color: state.agreedToEULA
? Theme.of(context).primaryColor
? Theme.of(context).colorScheme.primary
: Palette.grey,
),
onPressed: () =>
@ -136,7 +138,7 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
child: Text(
'End User Agreement',
style: TextStyle(
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
@ -182,15 +184,15 @@ class _LoginDialogState extends State<LoginDialog> with ItemActionMixin {
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
state.agreedToEULA
? Theme.of(context).primaryColor
? Theme.of(context).colorScheme.primary
: Palette.grey,
),
),
child: const Text(
child: Text(
'Log in',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Palette.white,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),

View File

@ -59,7 +59,12 @@ class MainView extends StatelessWidget {
if (context.read<StoriesBloc>().state.isOfflineReading ==
false &&
state.onlyShowTargetComment == false) {
unawaited(context.read<CommentsCubit>().refresh());
unawaited(
context.read<CommentsCubit>().refresh(
onError: (AppException e) =>
context.showErrorSnackBar(e.message),
),
);
if (state.item.isPoll) {
context.read<PollCubit>().refresh();
@ -145,27 +150,28 @@ class MainView extends StatelessWidget {
},
),
),
Positioned(
height: Dimens.pt4,
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState prev, CommentsState current) =>
prev.status != current.status,
builder: (BuildContext context, CommentsState state) {
return AnimatedOpacity(
opacity: state.status == CommentsStatus.inProgress
? NumSwitch.on
: NumSwitch.off,
duration: const Duration(
milliseconds: _loadingIndicatorOpacityAnimationDuration,
),
child: const LinearProgressIndicator(),
);
},
if (context.read<PreferenceCubit>().state.devModeEnabled)
Positioned(
height: Dimens.pt4,
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState prev, CommentsState current) =>
prev.status != current.status,
builder: (BuildContext context, CommentsState state) {
return AnimatedOpacity(
opacity: state.status == CommentsStatus.inProgress
? NumSwitch.on
: NumSwitch.off,
duration: const Duration(
milliseconds: _loadingIndicatorOpacityAnimationDuration,
),
child: const LinearProgressIndicator(),
);
},
),
),
),
],
);
}
@ -190,9 +196,6 @@ class _ParentItemSection extends StatelessWidget {
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped;
static const double _viewParentButtonWidth = 100;
static const double _viewRootButtonWidth = 85;
@override
Widget build(BuildContext context) {
final Item item = state.item;
@ -221,14 +224,14 @@ class _ParentItemSection extends StatelessWidget {
}
context.read<EditCubit>().onReplyTapped(item);
},
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(item, context.rect),
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
icon: Icons.more_horiz,
),
@ -246,19 +249,17 @@ class _ParentItemSection extends StatelessWidget {
Text(
item.by,
style: TextStyle(
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
textScaler: MediaQuery.of(context).textScaler,
),
const Spacer(),
Text(
item.timeAgo,
style: const TextStyle(
color: Palette.grey,
style: TextStyle(
color: Theme.of(context).metadataColor,
),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
textScaler: MediaQuery.of(context).textScaler,
),
],
),
@ -306,7 +307,7 @@ class _ParentItemSection extends StatelessWidget {
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
top: Dimens.pt6,
),
child: Text.rich(
TextSpan(
@ -318,7 +319,9 @@ class _ParentItemSection extends StatelessWidget {
fontWeight: FontWeight.bold,
fontSize: fontSize,
color: item.url.isNotEmpty
? Theme.of(context).primaryColor
? Theme.of(context)
.colorScheme
.primary
: null,
),
),
@ -328,15 +331,15 @@ class _ParentItemSection extends StatelessWidget {
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: fontSize - 4,
color:
Theme.of(context).primaryColor,
color: Theme.of(context)
.colorScheme
.primary,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
textScaler: MediaQuery.of(context).textScaler,
),
),
)
@ -354,8 +357,8 @@ class _ParentItemSection extends StatelessWidget {
),
child: ItemText(
item: item,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
textScaler:
MediaQuery.of(context).textScaler,
selectable: true,
),
),
@ -393,29 +396,31 @@ class _ParentItemSection extends StatelessWidget {
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
Text(
'''${item.score} karma, ${item.descendants} comment${item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
SizedBox(
height: 48,
child: Row(
children: <Widget>[
if (item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
textScaleFactor: 1,
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
SizedBox(
width: _viewParentButtonWidth,
child: TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child:
state.fetchParentStatus == CommentsStatus.inProgress
Text(
'''${item.score} karma, ${item.descendants} cmt${item.descendants > 1 ? 's' : ''}''',
style: Theme.of(context).textTheme.labelLarge,
textScaler: MediaQuery.of(context).clampedTextScaler,
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
BlocSelector<CommentsCubit, CommentsState, CommentsStatus>(
selector: (CommentsState state) =>
state.fetchParentStatus,
builder: (BuildContext context, CommentsStatus status) {
return TextButton(
onPressed:
context.read<CommentsCubit>().loadParentThread,
child: status == CommentsStatus.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
@ -423,84 +428,73 @@ class _ParentItemSection extends StatelessWidget {
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent',
style: TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
: Text(
'View Parent',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
),
textScaler:
MediaQuery.of(context).clampedTextScaler,
),
);
},
),
BlocSelector<CommentsCubit, CommentsState, CommentsStatus>(
selector: (CommentsState state) => state.fetchRootStatus,
builder: (BuildContext context, CommentsStatus status) {
return TextButton(
onPressed:
context.read<CommentsCubit>().loadRootThread,
child: status == CommentsStatus.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: Text(
'View Root',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
),
textScaler:
MediaQuery.of(context).clampedTextScaler,
),
);
},
),
],
const Spacer(),
if (!state.isOfflineReading)
CustomDropdownMenu<FetchMode>(
menuChildren: FetchMode.values,
onSelected: context.read<CommentsCubit>().updateFetchMode,
selected: state.fetchMode,
),
const SizedBox(
width: Dimens.pt6,
),
SizedBox(
width: _viewRootButtonWidth,
child: TextButton(
onPressed: context.read<CommentsCubit>().loadRootThread,
child: state.fetchRootStatus == CommentsStatus.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View root',
style: TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
CustomDropdownMenu<CommentsOrder>(
menuChildren: CommentsOrder.values,
onSelected: context.read<CommentsCubit>().updateOrder,
selected: state.order,
),
const SizedBox(
width: Dimens.pt4,
),
],
const Spacer(),
if (!state.isOfflineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().updateFetchMode,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().updateOrder,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
),
const Divider(
height: Dimens.zero,
@ -517,6 +511,9 @@ class _ParentItemSection extends StatelessWidget {
style: TextStyle(color: Palette.grey),
),
),
const SizedBox(
height: 120,
),
],
],
),

View File

@ -83,7 +83,7 @@ class MorePopupMenu extends StatelessWidget {
children: <Widget>[
AnimatedCrossFade(
alignment: Alignment.center,
duration: Durations.ms300,
duration: AppDurations.ms300,
crossFadeState: state.status.isLoading
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
@ -137,7 +137,9 @@ class MorePopupMenu extends StatelessWidget {
),
linkStyle: TextStyle(
fontSize: fontSize,
color: Theme.of(context).primaryColor,
color: Theme.of(context)
.colorScheme
.primary,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(
@ -182,12 +184,12 @@ class MorePopupMenu extends StatelessWidget {
ListTile(
leading: Icon(
FeatherIcons.chevronUp,
color: upvoted ? Theme.of(context).primaryColor : null,
color: upvoted ? Theme.of(context).colorScheme.primary : null,
),
title: Text(
upvoted ? 'Upvoted' : 'Upvote',
style: upvoted
? TextStyle(color: Theme.of(context).primaryColor)
? TextStyle(color: Theme.of(context).colorScheme.primary)
: null,
),
subtitle: item is Story ? Text(item.score.toString()) : null,
@ -196,12 +198,13 @@ class MorePopupMenu extends StatelessWidget {
ListTile(
leading: Icon(
FeatherIcons.chevronDown,
color: downvoted ? Theme.of(context).primaryColor : null,
color:
downvoted ? Theme.of(context).colorScheme.primary : null,
),
title: Text(
downvoted ? 'Downvoted' : 'Downvote',
style: downvoted
? TextStyle(color: Theme.of(context).primaryColor)
? TextStyle(color: Theme.of(context).colorScheme.primary)
: null,
),
onTap: context.read<VoteCubit>().downvote,
@ -212,7 +215,8 @@ class MorePopupMenu extends StatelessWidget {
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Theme.of(context).primaryColor : null,
color:
isFav ? Theme.of(context).colorScheme.primary : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',

View File

@ -37,8 +37,8 @@ class PinIconButton extends StatelessWidget {
child: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: pinned
? Theme.of(context).primaryColor
: Theme.of(context).iconTheme.color,
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
onPressed: () {

View File

@ -106,7 +106,7 @@ class _PollViewState extends State<PollView> with ItemActionMixin {
icon: Icon(
Icons.arrow_drop_up,
color: voteState.vote == Vote.up
? Theme.of(context).primaryColor
? Theme.of(context).colorScheme.primary
: Palette.grey,
size: TextDimens.pt36,
),
@ -130,7 +130,8 @@ class _PollViewState extends State<PollView> with ItemActionMixin {
),
LinearProgressIndicator(
value: option.ratio,
color: Theme.of(context).primaryColor,
color:
Theme.of(context).colorScheme.primary,
),
],
),

View File

@ -73,7 +73,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
height: editState.showReplyBox
? (expanded ? expandedHeight : collapsedHeight)
: Dimens.zero,
duration: Durations.ms200,
duration: AppDurations.ms200,
decoration: BoxDecoration(
boxShadow: <BoxShadow>[
if (!context.read<SplitViewCubit>().state.enabled &&
@ -97,7 +97,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
),
AnimatedContainer(
height: expanded ? Dimens.pt40 : Dimens.zero,
duration: Durations.ms300,
duration: AppDurations.ms300,
),
Row(
children: <Widget>[
@ -125,12 +125,13 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
AnimatedOpacity(
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: Durations.ms300,
duration: AppDurations.ms300,
child: IconButton(
key: const Key('quote'),
icon: Icon(
FeatherIcons.code,
color: Theme.of(context).primaryColor,
color:
Theme.of(context).colorScheme.primary,
size: TextDimens.pt18,
),
onPressed: expanded ? showTextPopup : null,
@ -142,7 +143,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
expanded
? FeatherIcons.minimize2
: FeatherIcons.maximize2,
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
size: TextDimens.pt18,
),
onPressed: () {
@ -156,7 +157,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
key: const Key('close'),
icon: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
),
onPressed: () {
setState(() {
@ -209,7 +210,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
height: Dimens.pt24,
width: Dimens.pt24,
child: CircularProgressIndicator(
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
strokeWidth: Dimens.pt2,
),
),
@ -219,7 +220,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
key: const Key('send'),
icon: Icon(
Icons.send,
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
),
onPressed: () {
widget.onSendTapped();
@ -349,7 +350,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
IconButton(
icon: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
size: TextDimens.pt18,
),
onPressed: () => context.pop(),
@ -370,8 +371,7 @@ class _ReplyBoxState extends State<ReplyBox> with ItemActionMixin {
child: ItemText(
item: replyingTo,
selectable: true,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
textScaler: MediaQuery.of(context).textScaler,
),
),
),

View File

@ -31,7 +31,7 @@ class _ProfileScreenState extends State<ProfileScreen>
final RefreshController refreshControllerFav = RefreshController();
final RefreshController refreshControllerNotification = RefreshController();
final ScrollController scrollController = ScrollController();
final Throttle throttle = Throttle(delay: Durations.twoSeconds);
final Throttle throttle = Throttle(delay: AppDurations.twoSeconds);
PageType? pageType;
@ -138,13 +138,52 @@ class _ProfileScreenState extends State<ProfileScreen>
..loadComplete();
}
},
buildWhen: (FavState previous, FavState current) =>
previous.favItems.length != current.favItems.length,
builder: (BuildContext context, FavState favState) {
Widget? header() => authState.isLoggedIn
? BlocSelector<FavCubit, FavState, Status>(
selector: (FavState state) => state.mergeStatus,
builder: (
BuildContext context,
Status status,
) {
return TextButton(
onPressed: () =>
context.read<FavCubit>().merge(
onError: (AppException e) =>
showErrorSnackBar(e.message),
onSuccess: () => showSnackBar(
content: '''Sync completed.''',
),
),
child: status == Status.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child:
CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text('Sync from Hacker News'),
);
},
)
: null;
if (favState.favItems.isEmpty &&
favState.status != Status.inProgress) {
return const CenteredMessageView(
content: 'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
return Column(
children: <Widget>[
header() ?? const SizedBox.shrink(),
const CenteredMessageView(
content:
'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
),
],
);
}
@ -181,6 +220,7 @@ class _ProfileScreenState extends State<ProfileScreen>
onTap: (Item item) => goToItemScreen(
args: ItemScreenArgs(item: item),
),
header: header(),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,

View File

@ -28,9 +28,6 @@ class InboxView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final Color textColor = Theme.of(context).brightness == Brightness.dark
? Palette.white
: Palette.black;
return Column(
children: <Widget>[
if (unreadCommentsIds.isNotEmpty)
@ -42,7 +39,8 @@ class InboxView extends StatelessWidget {
child: SmartRefresher(
enablePullUp: true,
header: WaterDropMaterialHeader(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.onPrimary,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
@ -91,8 +89,8 @@ class InboxView extends StatelessWidget {
children: <Widget>[
Text(
'''${e.timeAgo} from ${e.by}:''',
style: const TextStyle(
color: Palette.grey,
style: TextStyle(
color: Theme.of(context).metadataColor,
),
),
const SizedBox(
@ -104,13 +102,16 @@ class InboxView extends StatelessWidget {
text: e.text,
style: TextStyle(
color: unreadCommentsIds.contains(e.id)
? textColor
: Palette.grey,
? Theme.of(context)
.colorScheme
.onSurface
: Theme.of(context).readGrey,
fontSize: TextDimens.pt16,
),
linkStyle: TextStyle(
color: Theme.of(context)
.primaryColor
.colorScheme
.primary
.withOpacity(
unreadCommentsIds.contains(e.id)
? 1

View File

@ -41,12 +41,12 @@ class OfflineListTile extends StatelessWidget {
} else if (downloaded) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
);
}
return Icon(
Icons.download,
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
);
}();

View File

@ -82,34 +82,10 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
const SizedBox(
height: Dimens.pt8,
),
const Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: <Widget>[
SizedBox(
width: Dimens.pt16,
),
Text('Default fetch mode'),
Spacer(),
],
),
),
Flexible(
child: Row(
children: <Widget>[
Text('Default comments order'),
Spacer(),
],
),
),
],
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Flexible(
child: Row(
@ -117,75 +93,73 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
const SizedBox(
width: Dimens.pt16,
),
DropdownButton<FetchMode>(
value: preferenceState.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) =>
DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt16,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('Default fetch mode'),
DropdownMenu<FetchMode>(
initialSelection: preferenceState.fetchMode,
dropdownMenuEntries: FetchMode.values
.map(
(FetchMode val) =>
DropdownMenuEntry<FetchMode>(
value: val,
label: val.description,
),
),
),
)
.toList(),
onChanged: (FetchMode? fetchMode) {
if (fetchMode != null) {
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
FetchModePreference(
val: fetchMode.index,
),
);
}
},
)
.toList(),
onSelected: (FetchMode? fetchMode) {
if (fetchMode != null) {
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
FetchModePreference(
val: fetchMode.index,
),
);
}
},
),
],
),
const Spacer(),
],
),
),
Flexible(
child: Row(
children: <Widget>[
DropdownButton<CommentsOrder>(
value: preferenceState.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) =>
DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt16,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('Default comments order'),
DropdownMenu<CommentsOrder>(
initialSelection: preferenceState.order,
dropdownMenuEntries: CommentsOrder.values
.map(
(CommentsOrder val) =>
DropdownMenuEntry<CommentsOrder>(
value: val,
label: val.description,
),
)
.toList(),
onSelected: (CommentsOrder? order) {
if (order != null) {
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
CommentsOrderPreference(
val: order.index,
),
),
)
.toList(),
onChanged: (CommentsOrder? order) {
if (order != null) {
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
CommentsOrderPreference(
val: order.index,
),
);
}
},
),
const Spacer(),
],
),
);
}
},
),
],
),
const SizedBox(
width: Dimens.pt16,
),
],
),
const SizedBox(
height: Dimens.pt12,
),
const TabBarSettings(),
const TextScaleFactorSettings(),
const Divider(),
@ -228,41 +202,19 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
.add(ClearAllReadStories());
}
},
activeColor: Theme.of(context).primaryColor,
activeColor: Theme.of(context).colorScheme.primary,
),
if (preference
is MarkReadStoriesModePreference) ...<Widget>[
ListTile(
title: Text(
StoryMarkingModePreference().title,
style: TextStyle(
color: !preferenceState.markReadStoriesEnabled
? Palette.grey
: null,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt16,
),
trailing: DropdownButton<StoryMarkingMode>(
value: preferenceState.storyMarkingMode,
underline: const SizedBox.shrink(),
items: StoryMarkingMode.values
.map(
(StoryMarkingMode val) =>
DropdownMenuItem<StoryMarkingMode>(
value: val,
child: Text(
val.label,
style: TextStyle(
fontSize: TextDimens.pt16,
color: !preferenceState
.markReadStoriesEnabled
? Palette.grey
: null,
),
),
),
)
.toList(),
onChanged: (StoryMarkingMode? storyMarkingMode) {
child: DropdownMenu<StoryMarkingMode>(
enabled: preferenceState.markReadStoriesEnabled,
label: Text(StoryMarkingModePreference().title),
initialSelection: preferenceState.storyMarkingMode,
onSelected: (StoryMarkingMode? storyMarkingMode) {
if (storyMarkingMode != null) {
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
@ -272,6 +224,23 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
);
}
},
dropdownMenuEntries: StoryMarkingMode.values
.map(
(StoryMarkingMode val) =>
DropdownMenuEntry<StoryMarkingMode>(
value: val,
label: val.label,
),
)
.toList(),
inputDecorationTheme: const InputDecorationTheme(
disabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Palette.grey,
),
),
),
expandedInsets: EdgeInsets.zero,
),
),
const Divider(),
@ -332,6 +301,17 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
title: const Text('About'),
subtitle: const Text('nothing interesting here.'),
onTap: showAboutHackiDialog,
onLongPress: () {
final DevMode updatedDevMode =
DevMode(val: !preferenceState.devModeEnabled);
context.read<PreferenceCubit>().update(updatedDevMode);
HapticFeedbackUtil.heavy();
if (updatedDevMode.val) {
showSnackBar(content: 'You are a dev now.');
} else {
showSnackBar(content: 'Dev mode disabled');
}
},
),
const SizedBox(
height: Dimens.pt48,
@ -510,7 +490,7 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
child: Text(
'Cancel',
style: TextStyle(
color: Theme.of(context).primaryColor,
color: Theme.of(context).colorScheme.primary,
),
),
),
@ -529,6 +509,12 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
.whenComplete(
DefaultCacheManager().emptyCache,
)
.whenComplete(
locator.get<SembastRepository>().deleteCachedComments,
)
.whenComplete(
locator.get<SembastRepository>().deleteCachedMetadata,
)
.whenComplete(() {
showSnackBar(content: 'Cache cleared!');
});
@ -676,6 +662,9 @@ class _SettingsState extends State<Settings> with ItemActionMixin {
context: context,
builder: (BuildContext context) {
return AlertDialog(
actionsPadding: const EdgeInsets.all(
Dimens.pt16,
),
actions: <Widget>[
ElevatedButton(
onPressed: onSendEmailTapped,

View File

@ -14,7 +14,7 @@ class TextScaleFactorSettings extends StatelessWidget {
previous.textScaleFactor != current.textScaleFactor,
builder: (BuildContext context, PreferenceState state) {
final String label = state.textScaleFactor == 1
? '''system default ${MediaQuery.of(context).textScaleFactor.toStringAsPrecision(2)}'''
? '''system default'''
: state.textScaleFactor.toString();
return Column(
children: <Widget>[

View File

@ -32,9 +32,9 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
final ScrollController scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController();
final FocusNode focusNode = FocusNode();
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
final Debouncer debouncer = Debouncer(delay: AppDurations.oneSecond);
static const Duration chipsAnimationDuration = Durations.ms300;
static const Duration chipsAnimationDuration = AppDurations.ms300;
@override
void initState() {
@ -76,7 +76,8 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
enablePullDown: false,
enablePullUp: true,
header: WaterDropMaterialHeader(
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.onPrimary,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
@ -120,13 +121,16 @@ class _SearchScreenState extends State<SearchScreen> with ItemActionMixin {
child: TextField(
controller: textEditingController,
focusNode: focusNode,
cursorColor: Theme.of(context).primaryColor,
cursorColor:
Theme.of(context).colorScheme.primary,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Search Hacker News',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
color: Theme.of(context)
.colorScheme
.primary,
),
),
),

View File

@ -48,13 +48,14 @@ class PostedByFilterChip extends StatelessWidget {
),
child: TextField(
controller: usernameController,
cursorColor: Theme.of(context).primaryColor,
cursorColor: Theme.of(context).colorScheme.primary,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).primaryColor),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
),
@ -87,7 +88,7 @@ class PostedByFilterChip extends StatelessWidget {
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).primaryColor,
Theme.of(context).colorScheme.primary,
),
),
child: const Text(

Some files were not shown because too many files have changed in this diff Show More