mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
bfbc80b56b |
4
fastlane/metadata/android/en-US/changelogs/38.txt
Normal file
4
fastlane/metadata/android/en-US/changelogs/38.txt
Normal file
@ -0,0 +1,4 @@
|
||||
- You can now participate in polls.
|
||||
- Pick up where you left off.
|
||||
- Swipe left on comment tile to view its parents without scrolling all the way up.
|
||||
- Huge performance boost.
|
@ -13,4 +13,4 @@ Features:
|
||||
- Get in-app notification when there is new reply to your stories or comments.
|
||||
- Download stories for offline reading.
|
||||
- Pick up where you left off.
|
||||
- And more...
|
||||
- And more...
|
||||
|
@ -1 +1 @@
|
||||
Hacki is a simple noiseless Hacker News reader.
|
||||
Hacki is a simple noiseless Hacker News reader that is just enough.
|
@ -10,6 +10,8 @@ PODS:
|
||||
- flutter_inappwebview/Core (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- Flutter
|
||||
- FMDB (2.7.5):
|
||||
@ -32,11 +34,14 @@ PODS:
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- workmanager (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||
@ -45,6 +50,7 @@ DEPENDENCIES:
|
||||
- video_player (from `.symlinks/plugins/video_player/ios`)
|
||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@ -59,6 +65,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_inappwebview:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
path_provider_ios:
|
||||
@ -75,22 +83,26 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||
workmanager:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
|
||||
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
||||
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
shared_preferences_ios: aef470a42dc4675a1cdd50e3158b42e3d1232b32
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af
|
||||
video_player: ecd305f42e9044793efd34846e1ce64c31ea6fcb
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
||||
PODFILE CHECKSUM: cc1f88378b4bfcf93a6ce00d2c587857c6008d3b
|
||||
|
||||
|
@ -363,7 +363,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -372,7 +372,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.3;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -499,7 +499,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -508,7 +508,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.3;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -529,7 +529,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@ -538,7 +538,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.3;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -1,13 +1,34 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import workmanager
|
||||
import shared_preferences_ios
|
||||
import flutter_secure_storage
|
||||
import path_provider_ios
|
||||
import flutter_local_notifications
|
||||
|
||||
@UIApplicationMain
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
|
||||
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
}
|
||||
UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*15))
|
||||
|
||||
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
FLTSharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
|
||||
FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin")!)
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>workmanager.background.task</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@ -25,7 +29,9 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array/>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
@ -10,12 +10,12 @@ part 'auth_state.dart';
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc({
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? storageRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
}) : _authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_storageRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_sembastRepository =
|
||||
@ -30,7 +30,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
}
|
||||
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
|
||||
@ -108,7 +108,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
await _authRepository.logout();
|
||||
await _storageRepository.updateUnreadCommentsIds(<int>[]);
|
||||
await _preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||
await _sembastRepository.deleteAll();
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// Global [GetIt.instance].
|
||||
final GetIt locator = GetIt.instance;
|
||||
@ -15,5 +16,7 @@ Future<void> setUpLocator() async {
|
||||
..registerSingleton<PostRepository>(PostRepository())
|
||||
..registerSingleton<SembastRepository>(SembastRepository())
|
||||
..registerSingleton<CacheRepository>(CacheRepository())
|
||||
..registerSingleton<CacheService>(CacheService());
|
||||
..registerSingleton<CacheService>(CacheService())
|
||||
..registerSingleton<LocalNotification>(LocalNotification())
|
||||
..registerSingleton<Logger>(Logger());
|
||||
}
|
||||
|
@ -7,16 +7,16 @@ part 'blocklist_state.dart';
|
||||
|
||||
class BlocklistCubit extends Cubit<BlocklistState> {
|
||||
BlocklistCubit({PreferenceRepository? storageRepository})
|
||||
: _storageRepository =
|
||||
: _preferenceRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(BlocklistState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
void init() {
|
||||
_storageRepository.blocklist.then(
|
||||
_preferenceRepository.blocklist.then(
|
||||
(List<String> blocklist) => emit(state.copyWith(blocklist: blocklist)),
|
||||
);
|
||||
}
|
||||
@ -25,13 +25,13 @@ class BlocklistCubit extends Cubit<BlocklistState> {
|
||||
final List<String> updated = List<String>.from(state.blocklist)
|
||||
..add(username);
|
||||
emit(state.copyWith(blocklist: updated));
|
||||
_storageRepository.updateBlocklist(updated);
|
||||
_preferenceRepository.updateBlocklist(updated);
|
||||
}
|
||||
|
||||
void removeFromBlocklist(String username) {
|
||||
final List<String> updated = List<String>.from(state.blocklist)
|
||||
..remove(username);
|
||||
emit(state.copyWith(blocklist: updated));
|
||||
_storageRepository.updateBlocklist(updated);
|
||||
_preferenceRepository.updateBlocklist(updated);
|
||||
}
|
||||
}
|
||||
|
@ -110,21 +110,15 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||
),
|
||||
);
|
||||
|
||||
await _streamSubscription?.cancel();
|
||||
|
||||
final Story story = (state.item as Story?)!;
|
||||
final Story updatedStory =
|
||||
await _storiesRepository.fetchStoryBy(story.id) ?? story;
|
||||
|
||||
if (state.offlineReading) {
|
||||
_streamSubscription = _cacheRepository
|
||||
.getCachedCommentsStream(ids: updatedStory.kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
} else {
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(ids: updatedStory.kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
}
|
||||
_streamSubscription = _storiesRepository
|
||||
.fetchCommentsStream(ids: updatedStory.kids)
|
||||
.listen(_onCommentFetched)
|
||||
..onDone(_onDone);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
@ -11,12 +11,12 @@ class FavCubit extends Cubit<FavState> {
|
||||
FavCubit({
|
||||
required AuthBloc authBloc,
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? storageRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_storageRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
super(FavState.init()) {
|
||||
@ -25,7 +25,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
|
||||
final AuthBloc _authBloc;
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
static const int _pageSize = 20;
|
||||
String? _username;
|
||||
@ -33,7 +33,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
Future<void> init() async {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.username != _username) {
|
||||
_storageRepository
|
||||
_preferenceRepository
|
||||
.favList(of: authState.username)
|
||||
.then((List<int> favIds) {
|
||||
emit(
|
||||
@ -65,7 +65,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
Future<void> addFav(int id) async {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
await _storageRepository.addFav(username: username, id: id);
|
||||
await _preferenceRepository.addFav(username: username, id: id);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -91,7 +91,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
void removeFav(int id) {
|
||||
final String username = _authBloc.state.username;
|
||||
|
||||
_storageRepository.removeFav(username: username, id: id);
|
||||
_preferenceRepository.removeFav(username: username, id: id);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -147,7 +147,7 @@ class FavCubit extends Cubit<FavState> {
|
||||
),
|
||||
);
|
||||
|
||||
_storageRepository.favList(of: username).then((List<int> favIds) {
|
||||
_preferenceRepository.favList(of: username).then((List<int> favIds) {
|
||||
emit(state.copyWith(favIds: favIds));
|
||||
_storiesRepository
|
||||
.fetchStoriesStream(
|
||||
|
@ -16,21 +16,22 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
required AuthBloc authBloc,
|
||||
required PreferenceCubit preferenceCubit,
|
||||
StoriesRepository? storiesRepository,
|
||||
PreferenceRepository? storageRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_preferenceCubit = preferenceCubit,
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
_storageRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
super(NotificationState.init()) {
|
||||
_authBloc.stream.listen((AuthState authState) {
|
||||
if (authState.isLoggedIn && authState.username != _username) {
|
||||
// Get the user setting.
|
||||
_storageRepository.shouldShowNotification.then((bool showNotification) {
|
||||
_preferenceRepository.shouldShowNotification
|
||||
.then((bool showNotification) {
|
||||
if (showNotification) {
|
||||
init();
|
||||
}
|
||||
@ -56,12 +57,12 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
final AuthBloc _authBloc;
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
String? _username;
|
||||
Timer? _timer;
|
||||
|
||||
static const Duration _refreshDuration = Duration(minutes: 1);
|
||||
static const Duration _refreshInterval = Duration(minutes: 5);
|
||||
static const int _subscriptionUpperLimit = 15;
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@ -74,7 +75,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
emit(state.copyWith(allCommentsIds: commentIds));
|
||||
});
|
||||
|
||||
await _storageRepository.unreadCommentsIds.then((List<int> unreadIds) {
|
||||
await _preferenceRepository.unreadCommentsIds.then((List<int> unreadIds) {
|
||||
emit(state.copyWith(unreadCommentsIds: unreadIds));
|
||||
});
|
||||
|
||||
@ -103,21 +104,24 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
),
|
||||
);
|
||||
_initializeTimer();
|
||||
}).onError((Object? error, StackTrace stackTrace) => _initializeTimer());
|
||||
}).onError((Object? error, StackTrace stackTrace) {
|
||||
_initializeTimer();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
void markAsRead(Comment comment) {
|
||||
if (state.unreadCommentsIds.contains(comment.id)) {
|
||||
void markAsRead(int id) {
|
||||
if (state.unreadCommentsIds.contains(id)) {
|
||||
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
|
||||
..remove(comment.id);
|
||||
_storageRepository.updateUnreadCommentsIds(updatedUnreadIds);
|
||||
..remove(id);
|
||||
_preferenceRepository.updateUnreadCommentsIds(updatedUnreadIds);
|
||||
emit(state.copyWith(unreadCommentsIds: updatedUnreadIds));
|
||||
}
|
||||
}
|
||||
|
||||
void markAllAsRead() {
|
||||
emit(state.copyWith(unreadCommentsIds: <int>[]));
|
||||
_storageRepository.updateUnreadCommentsIds(<int>[]);
|
||||
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@ -144,6 +148,7 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
),
|
||||
);
|
||||
_initializeTimer();
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
emit(
|
||||
@ -184,7 +189,10 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
void _initializeTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(_refreshDuration, (Timer timer) => _fetchReplies());
|
||||
_timer = Timer.periodic(
|
||||
_refreshInterval,
|
||||
(Timer timer) => _fetchReplies(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchReplies() {
|
||||
@ -210,25 +218,30 @@ class NotificationCubit extends Cubit<NotificationState> {
|
||||
|
||||
if (diff.isNotEmpty) {
|
||||
for (final int newCommentId in diff) {
|
||||
await _storageRepository.updateUnreadCommentsIds(
|
||||
<int>[
|
||||
newCommentId,
|
||||
...state.unreadCommentsIds,
|
||||
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
|
||||
);
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: newCommentId)
|
||||
.then((Comment? comment) {
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
_sembastRepository
|
||||
..saveComment(comment)
|
||||
..updateIdsOfCommentsRepliedToMe(comment.id);
|
||||
final bool hasPushed =
|
||||
await _preferenceRepository.hasPushed(newCommentId);
|
||||
|
||||
// Add comment fetched to comments
|
||||
// and its id to unreadCommentsIds and allCommentsIds,
|
||||
emit(state.copyWithNewUnreadComment(comment: comment));
|
||||
}
|
||||
});
|
||||
if (!hasPushed) {
|
||||
await _preferenceRepository.updateUnreadCommentsIds(
|
||||
<int>[
|
||||
newCommentId,
|
||||
...state.unreadCommentsIds,
|
||||
]..sort((int lhs, int rhs) => rhs.compareTo(lhs)),
|
||||
);
|
||||
await _storiesRepository
|
||||
.fetchCommentBy(id: newCommentId)
|
||||
.then((Comment? comment) {
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
_sembastRepository
|
||||
..saveComment(comment)
|
||||
..updateIdsOfCommentsRepliedToMe(comment.id);
|
||||
|
||||
// Add comment fetched to comments
|
||||
// and its id to unreadCommentsIds and allCommentsIds,
|
||||
emit(state.copyWithNewUnreadComment(comment: comment));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -8,22 +8,22 @@ part 'pin_state.dart';
|
||||
|
||||
class PinCubit extends Cubit<PinState> {
|
||||
PinCubit({
|
||||
PreferenceRepository? storageRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
}) : _storageRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
}) : _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_storiesRepository =
|
||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||
super(PinState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
|
||||
void init() {
|
||||
emit(PinState.init());
|
||||
_storageRepository.pinnedStoriesIds.then((List<int> ids) {
|
||||
_preferenceRepository.pinnedStoriesIds.then((List<int> ids) {
|
||||
emit(state.copyWith(pinnedStoriesIds: ids));
|
||||
|
||||
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched);
|
||||
@ -38,7 +38,7 @@ class PinCubit extends Cubit<PinState> {
|
||||
pinnedStories: <Story>{story, ...state.pinnedStories}.toList(),
|
||||
),
|
||||
);
|
||||
_storageRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ class PinCubit extends Cubit<PinState> {
|
||||
pinnedStories: <Story>[...state.pinnedStories]..remove(story),
|
||||
),
|
||||
);
|
||||
_storageRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
|
||||
}
|
||||
|
||||
void _onStoryFetched(Story story) {
|
||||
|
@ -33,7 +33,7 @@ class PollCubit extends Cubit<PollState> {
|
||||
}
|
||||
}
|
||||
|
||||
// if pollOptionsIds is still empty, exit loading state.
|
||||
// If pollOptionsIds is still empty, exit loading state.
|
||||
if (pollOptionsIds.isEmpty) {
|
||||
emit(state.copyWith(status: PollStatus.loaded));
|
||||
return;
|
||||
|
@ -7,64 +7,64 @@ part 'preference_state.dart';
|
||||
|
||||
class PreferenceCubit extends Cubit<PreferenceState> {
|
||||
PreferenceCubit({PreferenceRepository? storageRepository})
|
||||
: _storageRepository =
|
||||
: _preferenceRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(const PreferenceState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
void init() {
|
||||
_storageRepository.shouldShowNotification
|
||||
_preferenceRepository.shouldShowNotification
|
||||
.then((bool value) => emit(state.copyWith(showNotification: value)));
|
||||
_storageRepository.shouldShowComplexStoryTile.then(
|
||||
_preferenceRepository.shouldShowComplexStoryTile.then(
|
||||
(bool value) => emit(state.copyWith(showComplexStoryTile: value)),
|
||||
);
|
||||
_storageRepository.shouldShowWebFirst
|
||||
_preferenceRepository.shouldShowWebFirst
|
||||
.then((bool value) => emit(state.copyWith(showWebFirst: value)));
|
||||
_storageRepository.shouldShowEyeCandy
|
||||
_preferenceRepository.shouldShowEyeCandy
|
||||
.then((bool value) => emit(state.copyWith(showEyeCandy: value)));
|
||||
_storageRepository.trueDarkMode
|
||||
_preferenceRepository.trueDarkMode
|
||||
.then((bool value) => emit(state.copyWith(useTrueDark: value)));
|
||||
_storageRepository.readerMode
|
||||
_preferenceRepository.readerMode
|
||||
.then((bool value) => emit(state.copyWith(useReader: value)));
|
||||
_storageRepository.markReadStories
|
||||
_preferenceRepository.markReadStories
|
||||
.then((bool value) => emit(state.copyWith(markReadStories: value)));
|
||||
}
|
||||
|
||||
void toggleNotificationMode() {
|
||||
emit(state.copyWith(showNotification: !state.showNotification));
|
||||
_storageRepository.toggleNotificationMode();
|
||||
_preferenceRepository.toggleNotificationMode();
|
||||
}
|
||||
|
||||
void toggleDisplayMode() {
|
||||
emit(state.copyWith(showComplexStoryTile: !state.showComplexStoryTile));
|
||||
_storageRepository.toggleDisplayMode();
|
||||
_preferenceRepository.toggleDisplayMode();
|
||||
}
|
||||
|
||||
void toggleNavigationMode() {
|
||||
emit(state.copyWith(showWebFirst: !state.showWebFirst));
|
||||
_storageRepository.toggleNavigationMode();
|
||||
_preferenceRepository.toggleNavigationMode();
|
||||
}
|
||||
|
||||
void toggleEyeCandyMode() {
|
||||
emit(state.copyWith(showEyeCandy: !state.showEyeCandy));
|
||||
_storageRepository.toggleEyeCandyMode();
|
||||
_preferenceRepository.toggleEyeCandyMode();
|
||||
}
|
||||
|
||||
void toggleTrueDarkMode() {
|
||||
emit(state.copyWith(useTrueDark: !state.useTrueDark));
|
||||
_storageRepository.toggleTrueDarkMode();
|
||||
_preferenceRepository.toggleTrueDarkMode();
|
||||
}
|
||||
|
||||
void toggleReaderMode() {
|
||||
emit(state.copyWith(useReader: !state.useReader));
|
||||
_storageRepository.toggleReaderMode();
|
||||
_preferenceRepository.toggleReaderMode();
|
||||
}
|
||||
|
||||
void toggleMarkReadStoriesMode() {
|
||||
emit(state.copyWith(markReadStories: !state.markReadStories));
|
||||
_storageRepository.toggleMarkReadStoriesMode();
|
||||
_preferenceRepository.toggleMarkReadStoriesMode();
|
||||
}
|
||||
}
|
||||
|
@ -14,22 +14,22 @@ class VoteCubit extends Cubit<VoteState> {
|
||||
required Item item,
|
||||
required AuthBloc authBloc,
|
||||
AuthRepository? authRepository,
|
||||
PreferenceRepository? storageRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
}) : _authBloc = authBloc,
|
||||
_authRepository = authRepository ?? locator.get<AuthRepository>(),
|
||||
_storageRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(VoteState.init(item: item)) {
|
||||
init();
|
||||
}
|
||||
|
||||
final AuthBloc _authBloc;
|
||||
final AuthRepository _authRepository;
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
static const int _karmaThreshold = 501;
|
||||
|
||||
Future<void> init() async {
|
||||
final bool? vote = await _storageRepository.vote(
|
||||
final bool? vote = await _preferenceRepository.vote(
|
||||
submittedTo: state.item.id,
|
||||
from: _authBloc.state.username,
|
||||
);
|
||||
@ -73,7 +73,7 @@ class VoteCubit extends Cubit<VoteState> {
|
||||
);
|
||||
|
||||
unawaited(
|
||||
_storageRepository.addVote(
|
||||
_preferenceRepository.addVote(
|
||||
username: _authBloc.state.username,
|
||||
id: state.item.id,
|
||||
vote: true,
|
||||
@ -88,7 +88,7 @@ class VoteCubit extends Cubit<VoteState> {
|
||||
}
|
||||
} else {
|
||||
await _authRepository.upvote(id: state.item.id, upvote: false);
|
||||
await _storageRepository.removeVote(
|
||||
await _preferenceRepository.removeVote(
|
||||
username: _authBloc.state.username,
|
||||
id: state.item.id,
|
||||
);
|
||||
@ -118,7 +118,7 @@ class VoteCubit extends Cubit<VoteState> {
|
||||
await _authRepository.downvote(id: state.item.id, downvote: true);
|
||||
|
||||
if (success) {
|
||||
await _storageRepository.addVote(
|
||||
await _preferenceRepository.addVote(
|
||||
username: _authBloc.state.username,
|
||||
id: state.item.id,
|
||||
vote: false,
|
||||
@ -133,7 +133,7 @@ class VoteCubit extends Cubit<VoteState> {
|
||||
}
|
||||
} else {
|
||||
await _authRepository.downvote(id: state.item.id, downvote: false);
|
||||
await _storageRepository.removeVote(
|
||||
await _preferenceRepository.removeVote(
|
||||
username: _authBloc.state.username,
|
||||
id: state.item.id,
|
||||
);
|
||||
|
@ -1,20 +1,61 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/custom_router.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/fetcher.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
// For receiving payload event from local notifications.
|
||||
final BehaviorSubject<String?> selectNotificationSubject =
|
||||
BehaviorSubject<String?>();
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (Platform.isIOS) {
|
||||
unawaited(
|
||||
Workmanager().initialize(
|
||||
fetcherCallbackDispatcher,
|
||||
),
|
||||
);
|
||||
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const IOSInitializationSettings initializationSettingsIOS =
|
||||
IOSInitializationSettings();
|
||||
const InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsIOS,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onSelectNotification: selectNotificationSubject.add,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
}
|
||||
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final String tempPath = tempDir.path;
|
||||
Hive.init(tempPath);
|
||||
|
@ -10,21 +10,21 @@ import 'package:hacki/utils/service_exception.dart';
|
||||
class AuthRepository {
|
||||
AuthRepository({
|
||||
Dio? dio,
|
||||
PreferenceRepository? storageRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
}) : _dio = dio ?? Dio(),
|
||||
_storageRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>();
|
||||
_preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>();
|
||||
|
||||
static const String _authority = 'news.ycombinator.com';
|
||||
|
||||
final Dio _dio;
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
Future<bool> get loggedIn async => _storageRepository.loggedIn;
|
||||
Future<bool> get loggedIn async => _preferenceRepository.loggedIn;
|
||||
|
||||
Future<String?> get username async => _storageRepository.username;
|
||||
Future<String?> get username async => _preferenceRepository.username;
|
||||
|
||||
Future<String?> get password async => _storageRepository.password;
|
||||
Future<String?> get password async => _preferenceRepository.password;
|
||||
|
||||
Future<bool> login({
|
||||
required String username,
|
||||
@ -40,16 +40,19 @@ class AuthRepository {
|
||||
final bool success = await _performDefaultPost(uri, data);
|
||||
|
||||
if (success) {
|
||||
await _storageRepository.setAuth(username: username, password: password);
|
||||
await _preferenceRepository.setAuth(
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<bool> hasLoggedIn() => _storageRepository.loggedIn;
|
||||
Future<bool> hasLoggedIn() => _preferenceRepository.loggedIn;
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storageRepository.removeAuth();
|
||||
await _preferenceRepository.removeAuth();
|
||||
}
|
||||
|
||||
Future<bool> flag({
|
||||
@ -57,8 +60,8 @@ class AuthRepository {
|
||||
required bool flag,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'flag');
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = FlagPostData(
|
||||
acct: username!,
|
||||
pw: password!,
|
||||
@ -74,8 +77,8 @@ class AuthRepository {
|
||||
required bool favorite,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'fave');
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = FavoritePostData(
|
||||
acct: username!,
|
||||
pw: password!,
|
||||
@ -91,8 +94,8 @@ class AuthRepository {
|
||||
required bool upvote,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'vote');
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = VotePostData(
|
||||
acct: username!,
|
||||
pw: password!,
|
||||
@ -108,8 +111,8 @@ class AuthRepository {
|
||||
required bool downvote,
|
||||
}) async {
|
||||
final Uri uri = Uri.https(_authority, 'vote');
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final PostDataMixin data = VotePostData(
|
||||
acct: username!,
|
||||
pw: password!,
|
||||
|
@ -9,20 +9,20 @@ import 'package:hacki/utils/utils.dart';
|
||||
class PostRepository {
|
||||
PostRepository({Dio? dio, PreferenceRepository? storageRepository})
|
||||
: _dio = dio ?? Dio(),
|
||||
_storageRepository =
|
||||
_preferenceRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>();
|
||||
|
||||
static const String _authority = 'news.ycombinator.com';
|
||||
|
||||
final Dio _dio;
|
||||
final PreferenceRepository _storageRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
Future<bool> comment({
|
||||
required int parentId,
|
||||
required String text,
|
||||
}) async {
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
final Uri uri = Uri.https(_authority, 'comment');
|
||||
|
||||
if (username == null || password == null) {
|
||||
@ -48,8 +48,8 @@ class PostRepository {
|
||||
String? url,
|
||||
String? text,
|
||||
}) async {
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
|
||||
if (username == null || password == null) {
|
||||
return false;
|
||||
@ -91,8 +91,8 @@ class PostRepository {
|
||||
required int id,
|
||||
String? text,
|
||||
}) async {
|
||||
final String? username = await _storageRepository.username;
|
||||
final String? password = await _storageRepository.password;
|
||||
final String? username = await _preferenceRepository.username;
|
||||
final String? password = await _preferenceRepository.password;
|
||||
|
||||
if (username == null || password == null) {
|
||||
return false;
|
||||
|
@ -115,6 +115,15 @@ class PreferenceRepository {
|
||||
return int.tryParse(val);
|
||||
});
|
||||
|
||||
Future<bool> hasPushed(int commentId) async =>
|
||||
_prefs.then((SharedPreferences prefs) {
|
||||
final bool? val = prefs.getBool(_getPushNotificationKey(commentId));
|
||||
|
||||
if (val == null) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
Future<List<int>> favList({required String of}) => _prefs.then(
|
||||
(SharedPreferences prefs) =>
|
||||
((prefs.getStringList(_getFavKey('')) ?? <String>[])
|
||||
@ -266,6 +275,16 @@ class PreferenceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateHasPushed(int commentId) async {
|
||||
final SharedPreferences prefs = await _prefs;
|
||||
await prefs.setBool(
|
||||
_getPushNotificationKey(commentId),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
String _getPushNotificationKey(int commentId) => 'pushed_$commentId';
|
||||
|
||||
String _getFavKey(String username) => 'fav_$username';
|
||||
|
||||
String _getVoteKey(String username, int id) => 'vote_$username-$id';
|
||||
|
@ -69,7 +69,7 @@ class SembastRepository {
|
||||
if (_idsOfCommentsRepliedToMe == null) {
|
||||
final RecordSnapshot<dynamic, dynamic>? snapshot =
|
||||
await store.record(_idsOfCommentsRepliedToMeKey).getSnapshot(db);
|
||||
list = snapshot?.value as List<int>? ?? <int>[];
|
||||
list = (snapshot?.value as List<dynamic>? ?? <int>[]).cast<int>();
|
||||
_idsOfCommentsRepliedToMe = list;
|
||||
} else {
|
||||
list = _idsOfCommentsRepliedToMe!;
|
||||
|
@ -161,6 +161,20 @@ class StoriesRepository {
|
||||
return comment;
|
||||
}
|
||||
|
||||
Future<Comment?> fetchRawCommentBy({required int id}) async {
|
||||
final Comment? comment = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) async {
|
||||
if (val == null) return null;
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
Future<Item?> fetchItemBy({required int id}) async {
|
||||
final Item? item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
@ -182,6 +196,28 @@ class StoriesRepository {
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<Item?> fetchRawItemBy({required int id}) async {
|
||||
final Item? item = await _firebaseClient
|
||||
.get('${_baseUrl}item/$id.json')
|
||||
.then((dynamic val) {
|
||||
if (val == null) return null;
|
||||
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
|
||||
final String type = json['type'] as String;
|
||||
if (type == 'story' || type == 'job') {
|
||||
final Story story = Story.fromJson(json);
|
||||
return story;
|
||||
} else if (json['type'] == 'comment') {
|
||||
final Comment comment = Comment.fromJson(json);
|
||||
return comment;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<List<int>?> fetchSubmitted({required String of}) async {
|
||||
final List<int>? submitted = await _firebaseClient
|
||||
.get('${_baseUrl}user/$of.json')
|
||||
@ -209,6 +245,17 @@ class StoriesRepository {
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
Future<Story?> fetchRawParentStory({required int id}) async {
|
||||
Item? item;
|
||||
|
||||
do {
|
||||
item = await fetchRawItemBy(id: item?.parent ?? id);
|
||||
if (item == null) return null;
|
||||
} while (item is Comment);
|
||||
|
||||
return item as Story;
|
||||
}
|
||||
|
||||
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({
|
||||
required int id,
|
||||
}) async {
|
||||
|
@ -1,5 +1,7 @@
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -12,8 +14,10 @@ import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
@ -54,6 +58,34 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
// Constants.featurePinToTop,
|
||||
// ]);
|
||||
|
||||
if (!selectNotificationSubject.hasListener) {
|
||||
selectNotificationSubject.stream.listen((String? payload) async {
|
||||
if (payload == null) return;
|
||||
|
||||
final Map<String, dynamic> payloadJson =
|
||||
jsonDecode(payload) as Map<String, dynamic>;
|
||||
|
||||
final int? storyId = payloadJson['storyId'] as int?;
|
||||
final int? commentId = payloadJson['commentId'] as int?;
|
||||
|
||||
if (storyId != null && commentId != null) {
|
||||
context.read<NotificationCubit>().markAsRead(commentId);
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchStoryBy(storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
showSnackBar(content: 'Something went wrong...');
|
||||
return;
|
||||
}
|
||||
final StoryScreenArgs args = StoryScreenArgs(story: story);
|
||||
goToStoryScreen(args: args);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
SchedulerBinding.instance?.addPostFrameCallback((_) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
|
@ -216,7 +216,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
then: () {
|
||||
context
|
||||
.read<NotificationCubit>()
|
||||
.markAsRead(cmt);
|
||||
.markAsRead(cmt.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
@ -265,7 +265,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
subtitle: const Text(
|
||||
'Hacki scans for new replies to your 15 '
|
||||
'most recent comments or stories '
|
||||
'every 1 minute while the app is '
|
||||
'every 5 minutes while the app is '
|
||||
'running in the foreground.',
|
||||
),
|
||||
value: preferenceState.showNotification,
|
||||
@ -388,7 +388,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'Hacki',
|
||||
applicationVersion: 'v0.2.3',
|
||||
applicationVersion: 'v0.2.4',
|
||||
applicationIcon: Image.asset(
|
||||
Constants.hackiIconPath,
|
||||
height: 50,
|
||||
|
@ -1,34 +1,24 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:hacki/extensions/extensions.dart' show ObjectExtension;
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
class CustomBlocObserver extends BlocObserver {
|
||||
@override
|
||||
void onCreate(BlocBase<dynamic> bloc) {
|
||||
super.onCreate(bloc);
|
||||
bloc.log(identifier: 'Bloc Created:');
|
||||
}
|
||||
|
||||
@override
|
||||
void onEvent(Bloc<dynamic, dynamic> bloc, Object? event) {
|
||||
void onEvent(
|
||||
Bloc<dynamic, dynamic> bloc,
|
||||
Object? event,
|
||||
) {
|
||||
locator.get<Logger>().d(event);
|
||||
super.onEvent(bloc, event);
|
||||
event?.log(identifier: 'Bloc Event:');
|
||||
}
|
||||
|
||||
@override
|
||||
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
|
||||
super.onChange(bloc, change);
|
||||
change.log(identifier: 'Bloc Changed:');
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(BlocBase<dynamic> bloc, Object error, StackTrace stackTrace) {
|
||||
void onError(
|
||||
BlocBase<dynamic> bloc,
|
||||
Object error,
|
||||
StackTrace stackTrace,
|
||||
) {
|
||||
locator.get<Logger>().e(error);
|
||||
super.onError(bloc, error, stackTrace);
|
||||
error.log(identifier: 'Bloc Error:');
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose(BlocBase<dynamic> bloc) {
|
||||
super.onClose(bloc);
|
||||
bloc.log(identifier: 'Bloc Closed:');
|
||||
}
|
||||
}
|
||||
|
140
lib/services/fetcher.dart
Normal file
140
lib/services/fetcher.dart
Normal file
@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/utils/html_util.dart';
|
||||
import 'package:path_provider_android/path_provider_android.dart';
|
||||
import 'package:path_provider_ios/path_provider_ios.dart';
|
||||
import 'package:shared_preferences_android/shared_preferences_android.dart';
|
||||
import 'package:shared_preferences_ios/shared_preferences_ios.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
void fetcherCallbackDispatcher() {
|
||||
Workmanager()
|
||||
.executeTask((String task, Map<String, dynamic>? inputData) async {
|
||||
if (Platform.isAndroid) {
|
||||
PathProviderAndroid.registerWith();
|
||||
SharedPreferencesAndroid.registerWith();
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
PathProviderIOS.registerWith();
|
||||
SharedPreferencesIOS.registerWith();
|
||||
}
|
||||
|
||||
await Fetcher.fetchReplies();
|
||||
|
||||
return Future<bool>.value(true);
|
||||
});
|
||||
}
|
||||
|
||||
abstract class Fetcher {
|
||||
static const int _subscriptionUpperLimit = 15;
|
||||
|
||||
static Future<void> fetchReplies() async {
|
||||
final PreferenceRepository preferenceRepository = PreferenceRepository();
|
||||
final AuthRepository authRepository = AuthRepository(
|
||||
preferenceRepository: preferenceRepository,
|
||||
);
|
||||
final StoriesRepository storiesRepository = StoriesRepository();
|
||||
final SembastRepository sembastRepository = SembastRepository();
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
final String happyFace = Constants.happyFaces.pickRandomly()!;
|
||||
final String? username = await authRepository.username;
|
||||
final List<int> unreadIds = await preferenceRepository.unreadCommentsIds;
|
||||
|
||||
if (username == null || username.isEmpty) return;
|
||||
|
||||
Comment? newReply;
|
||||
|
||||
await storiesRepository
|
||||
.fetchSubmitted(of: username)
|
||||
.then((List<int>? submittedItems) async {
|
||||
if (submittedItems != null) {
|
||||
final List<int> subscribedItems = submittedItems.sublist(
|
||||
0,
|
||||
min(_subscriptionUpperLimit, submittedItems.length),
|
||||
);
|
||||
|
||||
for (final int id in subscribedItems) {
|
||||
await storiesRepository
|
||||
.fetchRawItemBy(id: id)
|
||||
.then((Item? item) async {
|
||||
final List<int> kids = item?.kids ?? <int>[];
|
||||
final List<int> previousKids =
|
||||
(await sembastRepository.kids(of: id)) ?? <int>[];
|
||||
|
||||
await sembastRepository.updateKidsOf(id: id, kids: kids);
|
||||
|
||||
final Set<int> diff =
|
||||
<int>{...kids}.difference(<int>{...previousKids});
|
||||
|
||||
if (diff.isNotEmpty) {
|
||||
for (final int newCommentId in diff) {
|
||||
if (unreadIds.contains(newCommentId)) continue;
|
||||
|
||||
await storiesRepository
|
||||
.fetchRawCommentBy(id: newCommentId)
|
||||
.then((Comment? comment) async {
|
||||
final bool hasPushedBefore =
|
||||
await preferenceRepository.hasPushed(newReply!.id);
|
||||
|
||||
if (comment != null && !comment.dead && !comment.deleted) {
|
||||
await sembastRepository.saveComment(comment);
|
||||
await sembastRepository.updateIdsOfCommentsRepliedToMe(
|
||||
comment.id,
|
||||
);
|
||||
|
||||
if (!hasPushedBefore) {
|
||||
newReply = comment;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newReply != null) break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newReply != null) break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push notification for new unread reply that has not been
|
||||
// pushed before.
|
||||
if (newReply != null) {
|
||||
final Story? story =
|
||||
await storiesRepository.fetchRawParentStory(id: newReply!.id);
|
||||
final String text = HtmlUtil.parseHtml(newReply!.text);
|
||||
|
||||
if (story != null) {
|
||||
final Map<String, int> payloadJson = <String, int>{
|
||||
'commentId': newReply!.id,
|
||||
'storyId': story.id,
|
||||
};
|
||||
final String payload = jsonEncode(payloadJson);
|
||||
|
||||
await preferenceRepository.updateHasPushed(newReply!.id);
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
newReply?.id ?? 0,
|
||||
'You have a new reply! $happyFace',
|
||||
'${newReply?.by}: $text',
|
||||
const NotificationDetails(
|
||||
iOS: IOSNotificationDetails(
|
||||
presentBadge: false,
|
||||
threadIdentifier: 'hacki',
|
||||
),
|
||||
),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
33
lib/services/local_notification.dart
Normal file
33
lib/services/local_notification.dart
Normal file
@ -0,0 +1,33 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
|
||||
class LocalNotification {
|
||||
Future<void> pushForNewReply(Comment newReply, int storyId) async {
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
final String happyFace = Constants.happyFaces.pickRandomly()!;
|
||||
|
||||
final Map<String, int> payloadJson = <String, int>{
|
||||
'commentId': newReply.id,
|
||||
'storyId': storyId,
|
||||
};
|
||||
final String payload = jsonEncode(payloadJson);
|
||||
|
||||
return flutterLocalNotificationsPlugin.show(
|
||||
newReply.id,
|
||||
'You have a new reply! $happyFace',
|
||||
'${newReply.by}: ${newReply.text}',
|
||||
const NotificationDetails(
|
||||
iOS: IOSNotificationDetails(
|
||||
presentBadge: false,
|
||||
threadIdentifier: 'hacki',
|
||||
),
|
||||
),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
export 'cache_service.dart';
|
||||
export 'custom_bloc_observer.dart';
|
||||
export 'fetcher.dart';
|
||||
export 'firebase_client.dart';
|
||||
export 'local_notification.dart';
|
||||
|
60
pubspec.lock
60
pubspec.lock
@ -334,7 +334,7 @@ packages:
|
||||
name: flutter_inappwebview
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.3.2"
|
||||
version: "5.4.3+4"
|
||||
flutter_layout_grid:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -349,6 +349,27 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.5.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_math_fork:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -527,6 +548,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
logger:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logger
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -640,14 +668,14 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.9"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.12"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider_ios
|
||||
url: "https://pub.dartlang.org"
|
||||
@ -759,7 +787,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rxdart
|
||||
url: "https://pub.dartlang.org"
|
||||
@ -780,19 +808,19 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.11"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.9"
|
||||
version: "2.0.11"
|
||||
shared_preferences_ios:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences_ios
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
version: "2.1.0"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -959,6 +987,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.9"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
tuple:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1176,13 +1211,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: workmanager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.2.0+1"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
12
pubspec.yaml
12
pubspec.yaml
@ -1,6 +1,6 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 0.2.3+37
|
||||
version: 0.2.4+38
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@ -24,8 +24,9 @@ dependencies:
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_html: ^2.2.1
|
||||
flutter_inappwebview: ^5.3.2
|
||||
flutter_inappwebview: ^5.4.3+4
|
||||
flutter_linkify: ^5.0.2
|
||||
flutter_local_notifications: ^9.5.0
|
||||
flutter_secure_storage: ^5.0.2
|
||||
flutter_slidable: ^1.2.0
|
||||
font_awesome_flutter: ^9.2.0
|
||||
@ -36,17 +37,24 @@ dependencies:
|
||||
html_unescape: ^2.0.0
|
||||
http: ^0.13.3
|
||||
intl: ^0.17.0
|
||||
logger: ^1.1.0
|
||||
path: ^1.8.0
|
||||
path_provider: ^2.0.8
|
||||
path_provider_android: ^2.0.8
|
||||
path_provider_ios: ^2.0.8
|
||||
pull_to_refresh: ^2.0.0
|
||||
responsive_builder: ^0.4.2
|
||||
rxdart: ^0.27.3
|
||||
sembast: ^3.1.1+1
|
||||
shared_preferences: ^2.0.11
|
||||
shared_preferences_android: ^2.0.11
|
||||
shared_preferences_ios: ^2.0.11
|
||||
shimmer: ^2.0.0
|
||||
tuple: ^2.0.0
|
||||
universal_platform: ^1.0.0+1
|
||||
url_launcher: ^6.0.10
|
||||
wakelock: ^0.6.1+2
|
||||
workmanager: ^0.4.1
|
||||
|
||||
dev_dependencies:
|
||||
bloc_test: ^9.0.3
|
||||
|
Reference in New Issue
Block a user