Compare commits

..

76 Commits

Author SHA1 Message Date
f70377d99c updated README.md 2022-03-25 18:19:05 -07:00
e190826fcf updated README.md 2022-03-25 09:37:21 -07:00
6f8dfaca8e updated README.md 2022-03-25 00:55:38 -07:00
889011e249 Merge pull request #10 from Livinglist/v0.2.1
V0.2.1
2022-03-24 19:24:52 -07:00
c141bd7ba5 added lock file. 2022-03-24 12:46:41 -07:00
d10e630c17 fixed UI. 2022-03-23 21:41:47 -07:00
5ae7769a36 Merge branch 'v0.2.1' of github.com:Livinglist/Hacki into v0.2.1 2022-03-23 16:04:49 -07:00
4f9761b2fa fixed inbox view. 2022-03-23 16:04:25 -07:00
c244435d01 updated README.md 2022-03-23 14:32:50 -07:00
db2de7aae4 Update README.md 2022-03-23 14:26:18 -07:00
35d7ffc09e enabled icloud. 2022-03-23 14:23:04 -07:00
dd4e56e31c changed tabbar style. 2022-03-23 13:17:35 -07:00
38f3efbe50 changed look of tab bar and story title. 2022-03-23 10:35:11 -07:00
4ebde84a79 bumped version. 2022-03-22 21:45:17 -07:00
382d2be5ba fixed link preview overflow. 2022-03-22 21:33:37 -07:00
7f66c80577 fixed preview pic size. 2022-03-22 17:40:21 -07:00
21d47cd54c optimized for ipad. 2022-03-22 17:25:13 -07:00
1139fa3c3b fixed copy dialog. 2022-03-22 16:22:31 -07:00
369a1e7bbf improved offline feature. 2022-03-22 16:03:59 -07:00
c3c86a4a44 fixed split view and keyboard. 2022-03-22 14:13:14 -07:00
5f39ad7df8 fixed refresh controller and reply box. 2022-03-22 10:44:30 -07:00
897ee81970 cleaned up code. 2022-03-21 22:08:35 -07:00
12098ce8cc added splitview. 2022-03-21 21:10:02 -07:00
b9636349d7 updated fastlane. 2022-03-08 19:02:47 -08:00
2028015977 fixed UI. 2022-03-08 19:01:39 -08:00
b0225303f3 removed pagination. 2022-03-08 18:46:50 -08:00
cf366927bc improved UI. 2022-03-08 18:34:24 -08:00
26ffe7535f upgraded packages. 2022-03-08 17:40:12 -08:00
b1e3ba5b0a changed scroll physics. 2022-03-08 17:35:17 -08:00
c71952c098 added pagination to story screen. 2022-03-08 17:18:31 -08:00
5fdfd42fda updated fastlane. 2022-03-07 17:00:44 -08:00
823021c673 fixed date time issue. 2022-03-07 16:51:06 -08:00
55f5e33210 fixed date time issue. 2022-03-07 16:47:22 -08:00
ee79bbe5c3 updated README.md 2022-03-07 01:22:06 -08:00
cf7f64e541 Merge pull request #9 from Livinglist/v0.2.0
v0.2.0
2022-03-07 01:19:38 -08:00
72ffc9d732 improved offline mode. 2022-03-07 01:13:30 -08:00
cc071b15c2 hide pinned stories in offline mode. 2022-03-07 00:46:18 -08:00
29cd5661f4 fixed delete all func. 2022-03-07 00:35:15 -08:00
3169944223 updated fastlane. 2022-03-07 00:24:53 -08:00
5e7d27e32f fixed profile screen about dialog. 2022-03-07 00:23:43 -08:00
8570968948 fixed scrolling animation. 2022-03-07 00:14:34 -08:00
27bbe23d0c fixed issues. 2022-03-06 22:29:19 -08:00
036fa2bbeb added offline mode. 2022-03-06 21:03:10 -08:00
c7cd8a918e updated fastlane. 2022-03-02 20:38:53 -08:00
8348a87a75 Merge pull request #7 from Livinglist/v0.1.9
fixed notification screen.
2022-03-02 20:21:48 -08:00
58837b6c00 fixed notification screen. 2022-03-02 20:21:25 -08:00
8365869ee8 Merge pull request #6 from Livinglist/v0.1.9
v0.1.9
2022-03-02 16:41:45 -08:00
5c70185236 fixed ui. 2022-03-02 16:37:58 -08:00
e4a385deb7 fixed imports 2022-03-02 16:13:58 -08:00
dfde6a74eb fixed inconsistent font size. 2022-03-02 16:10:26 -08:00
a2223dc531 added the feature where tapping on comment in notification or history screen will lead user directly to the comment. 2022-03-02 16:03:33 -08:00
f75e6a5e3b updated README.md 2022-02-28 21:27:18 -08:00
a87d521d32 updated README.md 2022-02-28 17:23:28 -08:00
94d76d4c20 removed firebase package.
removed firebase package.
2022-02-28 15:50:46 -08:00
1176e3bb80 removed firebase package. 2022-02-28 15:46:23 -08:00
52b63efe1a Merge pull request #4 from Livinglist/v0.1.8
v0.1.8
2022-02-28 11:08:59 -08:00
9652c08a4f added delete cache func. 2022-02-28 00:06:10 -08:00
ddb437cd60 fixed msg. 2022-02-27 19:52:42 -08:00
1719036d18 added edit comment feature. 2022-02-27 19:31:35 -08:00
6451495297 fixed UI. 2022-02-27 19:05:52 -08:00
d0b6f19a80 added mark read stories feature. 2022-02-27 19:03:46 -08:00
a77eb889f1 updated fastlane. 2022-02-23 19:15:21 -08:00
b35ffa2921 added haptic feedback. 2022-02-23 19:13:08 -08:00
d0d031600c updated README.md 2022-02-23 18:59:52 -08:00
a8d3002f31 Merge pull request #3 from Livinglist/v0.1.7
v0.1.7
2022-02-23 18:58:18 -08:00
a35aa6ea3b fixed order of feature discovery. 2022-02-23 18:18:02 -08:00
b2d4369b57 fixed routing. 2022-02-23 18:05:08 -08:00
fa3b28d050 updated version. 2022-02-23 17:34:15 -08:00
746dd61f48 updated fastlane. 2022-02-23 17:31:31 -08:00
29165bdb09 added slidable to comment tile. 2022-02-23 17:20:42 -08:00
4b9de44297 removed comment border option. 2022-02-23 15:51:44 -08:00
9e48be158b added pin button to story screen. 2022-02-23 15:49:25 -08:00
e64ea5e99a disabled feature discovery resetting. 2022-02-23 15:32:16 -08:00
0fce662954 fixed discovery overlay behavior. 2022-02-23 15:31:38 -08:00
b9b9d5f99f fixed link parsing. 2022-02-23 15:26:54 -08:00
1583525b48 added fastlane. 2022-02-20 01:24:49 -08:00
92 changed files with 2792 additions and 1158 deletions

1
.gitignore vendored
View File

@ -46,4 +46,3 @@ app.*.map.json
/android/app/release
/ios/Podfile.lock
pubspec.lock

View File

@ -1,8 +1,7 @@
# Hacki
# Hacki for Hacker News
A simple Hacker News reader made with Flutter.
A simple noiseless Hacker News reader made with Flutter that is just enough.
![iOS](https://img.shields.io/badge/iOS-13%20-blue)
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763)
[![Play Store](https://img.shields.io/badge/Play%20Store--yellow)](https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US)
[![Visits Badge](https://badges.pufler.dev/visits/livinglist/Hacki)](https://badges.pufler.dev)
@ -22,26 +21,33 @@ Features:
- Mark stories as favorite.
- Browse comments and stories you have posted in the past.
- Search for stories on Hacker News.
- Double tap to collapse a comment.
- Long press to vote on a comment or story.
- Swipe to right to pin a story to top.
- Collapse comments.
- Vote on comments or stories.
- Pin stories to the top of home page.
- Get in-app notification when there is new reply to your stories or comments.
- Download stories for offline reading.
- And more...
<p align="center">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859621-965080f3-a191-44cd-a2fc-9ac1f489ef84.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859627-48290a22-9679-442b-bae4-97f21546b3ae.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859630-93f7e372-f2e7-4357-86c0-250a3f69c10f.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148859632-b52a89ca-b8d7-464c-a508-faa86bcc87f8.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/148904175-8313d30a-ef84-4f3a-9ac2-f9e06021615d.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/150713047-2710add8-0493-4c42-a710-f96dc77cfde1.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/150918515-0fc4869f-efa3-473f-90af-381daf5e4915.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152305175-94fa3696-f40f-4f40-b040-f17fc59ff260.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152301588-070ded9a-117a-48d8-bad4-9f77d54d98df.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/152301590-5383200c-db73-487d-8742-57ccbbbf04e8.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/153973720-8a6aad44-7df3-4deb-8465-8c88b5e5f587.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/153973715-a33018d2-d3b1-4bfa-be39-56f5e3c4830b.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159799288-6e98352a-fe89-4a2e-8a74-c5782463a1e1.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159799297-75b52eac-2066-4df9-bdfc-7c82bf7b81c8.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159799302-860c61b8-abba-486a-9592-bc84a6af3232.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159799305-308743d3-1c89-45de-9645-3b6ec789c282.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798176-5212e9bf-296d-4d9b-ab48-19b741684c8a.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798179-72edbe49-7444-4e54-a07c-fc1244447a74.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798182-28397805-a7cc-4124-b65b-c02c80afbbec.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798183-c2984270-ee99-4419-841e-65e98890464f.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798184-2fce5d97-710e-44a7-b99a-3296ebcf273b.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798185-d7c81348-956e-483c-a1bc-5cd872bdad62.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798186-1457ae21-f1aa-40a4-9206-0f3a1e24653e.png">
<img width="200" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/159798187-4404adea-b2bc-472e-8568-2379e6db01a4.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/160162711-a9146326-9645-4db6-a04e-1f82e6133e40.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/160162726-ef1d3f2a-5179-417c-8a5f-0cddb52249da.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/160162733-906c4afd-39a8-48ae-946a-8019b327eaa0.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/160162735-f2b25119-4702-4308-b2f5-281a2a2c5901.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/160163024-6dcd65b6-bada-4c1c-95af-387fd4f42fb2.png">
<img width="400" alt="Screen Shot 2020-03-03 at 1 22 57 PM" src="https://user-images.githubusercontent.com/7277662/160163033-7bcf7038-b9aa-4ce4-8b58-64578eae8531.png">
</p>

View File

@ -0,0 +1 @@
- Bugfixes.

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Updates to UI.

View File

@ -0,0 +1 @@
- Tapping on comments in notification and history screen will lead you directly to the comment.

View File

@ -0,0 +1,3 @@
- Tapping on comment in notification or history screen will now lead you directly to the comment.
- Fixed the bug where reply box cannot be expanded in editing mode.
- Fixed inconsistent font size in history screen.

View File

@ -0,0 +1 @@
- Added offline mode.

View File

@ -0,0 +1,2 @@
- Added offline mode.
- Bugfixes.

View File

@ -0,0 +1,2 @@
- Added offline mode.
- Bugfixes.

View File

@ -0,0 +1,2 @@
- Added offline mode.
- Bugfixes.

View File

@ -0,0 +1,14 @@
Features:
- Log in using your Hacker News account.
- Browse stories from various categories.
- Submit links.
- Leave comments on stories.
- Mark stories as favorite.
- Browse comments and stories you have posted in the past.
- Search for stories on Hacker News.
- Collapse comments.
- Vote on comments or stories.
- Pin stories to the top of home page.
- Get in-app notification when there is new reply to your stories or comments.
- Download stories for offline reading.
- And more...

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

View File

@ -0,0 +1 @@
Hacki is a simple noiseless Hacker News reader.

View File

@ -0,0 +1 @@
Hacki for Hacker News

View File

@ -14,6 +14,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
D1B8F07E58D19B7A04062545 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A738A80ED7D259B7B74BB44E /* Pods_Runner.framework */; };
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -47,6 +48,8 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9F471B54216646F2690E5F66 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
A738A80ED7D259B7B74BB44E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E575B6EF27EBC6C6002B1508 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
E575B6F027EBC6DA002B1508 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -55,6 +58,7 @@
buildActionMask = 2147483647;
files = (
D1B8F07E58D19B7A04062545 /* Pods_Runner.framework in Frameworks */,
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -94,6 +98,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
E575B6EF27EBC6C6002B1508 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@ -109,6 +114,7 @@
B3F4F49CF582C662A01499C0 /* Frameworks */ = {
isa = PBXGroup;
children = (
E575B6F027EBC6DA002B1508 /* CloudKit.framework */,
A738A80ED7D259B7B74BB44E /* Pods_Runner.framework */,
);
name = Frameworks;
@ -354,9 +360,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -365,13 +372,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.6;
MARKETING_VERSION = 0.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
@ -489,9 +496,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -500,14 +508,14 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.6;
MARKETING_VERSION = 0.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@ -518,9 +526,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -529,13 +538,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.6;
MARKETING_VERSION = 0.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.jiaqi.hacki</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.com.jiaqi.hacki</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</dict>
</plist>

View File

@ -40,7 +40,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((loggedIn) async {
if (loggedIn) {
final username = await _authRepository.username;
final user = await _storiesRepository.fetchUserById(userId: username!);
final user = await _storiesRepository.fetchUserBy(userId: username!);
emit(state.copyWith(
isLoggedIn: true,
@ -48,6 +48,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
));
} else {
emit(state.copyWith(
status: AuthStatus.loaded,
isLoggedIn: false,
));
}
@ -73,8 +74,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
username: event.username, password: event.password);
if (successful) {
final user =
await _storiesRepository.fetchUserById(userId: event.username);
final user = await _storiesRepository.fetchUserBy(userId: event.username);
emit(state.copyWith(
user: user,
isLoggedIn: true,

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
@ -10,8 +12,10 @@ part 'stories_state.dart';
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesBloc({
CacheRepository? cacheRepository,
StoriesRepository? storiesRepository,
}) : _storiesRepository =
}) : _cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(const StoriesState.init()) {
on<StoriesInitialize>(onInitialize);
@ -19,43 +23,80 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoriesLoadMore>(onLoadMore);
on<StoryLoaded>(onStoryLoaded);
on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload);
on<StoriesExitOffline>(onExitOffline);
add(StoriesInitialize());
}
final CacheRepository _cacheRepository;
final StoriesRepository _storiesRepository;
static const _pageSize = 20;
Future<void> loadTopStories(
Future<void> loadStories(
{required StoryType of, required Emitter<StoriesState> emit}) async {
final ids = await _storiesRepository.fetchStoryIds(of: of);
emit(state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0));
_storiesRepository
.fetchStoriesStream(ids: ids.sublist(0, 20))
.listen((story) {
add(StoryLoaded(story: story, type: of));
}).onDone(() {
add(StoriesLoaded(type: of));
});
if (state.offlineReading) {
final ids = await _cacheRepository.getCachedStoryIds(of: of);
emit(state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0));
_cacheRepository
.getCachedStoriesStream(
ids: ids.sublist(0, min(ids.length, _pageSize)))
.listen((story) {
add(StoryLoaded(story: story, type: of));
}).onDone(() {
add(StoriesLoaded(type: of));
});
} else {
final ids = await _storiesRepository.fetchStoryIds(of: of);
emit(state
.copyWithStoryIdsUpdated(of: of, to: ids)
.copyWithCurrentPageUpdated(of: of, to: 0));
_storiesRepository
.fetchStoriesStream(ids: ids.sublist(0, _pageSize))
.listen((story) {
add(StoryLoaded(story: story, type: of));
}).onDone(() {
add(StoriesLoaded(type: of));
});
}
}
Future<void> onInitialize(
StoriesInitialize event, Emitter<StoriesState> emit) async {
await loadTopStories(of: StoryType.top, emit: emit);
await loadTopStories(of: StoryType.latest, emit: emit);
await loadTopStories(of: StoryType.ask, emit: emit);
await loadTopStories(of: StoryType.show, emit: emit);
await loadTopStories(of: StoryType.jobs, emit: emit);
final hasCachedStories = await _cacheRepository.hasCachedStories;
emit(state.copyWith(offlineReading: hasCachedStories));
await loadStories(of: StoryType.top, emit: emit);
await loadStories(of: StoryType.latest, emit: emit);
await loadStories(of: StoryType.ask, emit: emit);
await loadStories(of: StoryType.show, emit: emit);
await loadStories(of: StoryType.jobs, emit: emit);
}
Future<void> onRefresh(
StoriesRefresh event, Emitter<StoriesState> emit) async {
emit(state.copyWithRefreshed(of: event.type));
await loadTopStories(of: event.type, emit: emit);
emit(state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loading,
));
if (state.offlineReading) {
emit(state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loaded,
));
} else {
emit(state.copyWithRefreshed(of: event.type));
await loadStories(of: event.type, emit: emit);
}
}
void onLoadMore(StoriesLoadMore event, Emitter<StoriesState> emit) {
emit(state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loading,
));
final currentPage = state.currentPageByType[event.type]!;
final len = state.storyIdsByType[event.type]!.length;
emit(state.copyWithCurrentPageUpdated(of: event.type, to: currentPage + 1));
@ -67,18 +108,37 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
upper = len;
}
_storiesRepository
.fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
))
.listen((story) {
add(StoryLoaded(
story: story,
type: event.type,
));
});
if (state.offlineReading) {
_cacheRepository
.getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
))
.listen((story) {
add(StoryLoaded(
story: story,
type: event.type,
));
}).onDone(() {
add(StoriesLoaded(type: event.type));
});
} else {
_storiesRepository
.fetchStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
lower,
upper,
))
.listen((story) {
add(StoryLoaded(
story: story,
type: event.type,
));
}).onDone(() {
add(StoriesLoaded(type: event.type));
});
}
} else {
emit(state.copyWithStatusUpdated(
of: event.type, to: StoriesStatus.loaded));
@ -87,17 +147,66 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
void onStoryLoaded(StoryLoaded event, Emitter<StoriesState> emit) {
emit(state.copyWithStoryAdded(of: event.type, story: event.story));
if (state.storiesByType[event.type]!.length % _pageSize == 0) {
emit(
state.copyWithStatusUpdated(
of: event.type,
to: StoriesStatus.loaded,
),
);
}
}
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
emit(state.copyWithStatusUpdated(of: event.type, to: StoriesStatus.loaded));
}
Future<void> onDownload(
StoriesDownload event, Emitter<StoriesState> emit) async {
emit(state.copyWith(
downloadStatus: StoriesDownloadStatus.downloading,
));
await _cacheRepository.deleteAllStoryIds();
await _cacheRepository.deleteAllStories();
await _cacheRepository.deleteAllComments();
final topIds = await _storiesRepository.fetchStoryIds(of: StoryType.top);
final newIds = await _storiesRepository.fetchStoryIds(of: StoryType.latest);
final askIds = await _storiesRepository.fetchStoryIds(of: StoryType.ask);
final showIds = await _storiesRepository.fetchStoryIds(of: StoryType.show);
final jobIds = await _storiesRepository.fetchStoryIds(of: StoryType.jobs);
await _cacheRepository.cacheStoryIds(of: StoryType.top, ids: topIds);
await _cacheRepository.cacheStoryIds(of: StoryType.latest, ids: newIds);
await _cacheRepository.cacheStoryIds(of: StoryType.ask, ids: askIds);
await _cacheRepository.cacheStoryIds(of: StoryType.show, ids: showIds);
await _cacheRepository.cacheStoryIds(of: StoryType.jobs, ids: jobIds);
final allIds = [...topIds, ...newIds, ...askIds, ...showIds, ...jobIds];
try {
_storiesRepository.fetchStoriesStream(ids: allIds).listen((story) async {
if (story.kids.isNotEmpty) {
await _cacheRepository.cacheStory(story: story);
_storiesRepository
.fetchAllChildrenComments(ids: story.kids)
.listen((comment) async {
if (comment != null) {
await _cacheRepository.cacheComment(comment: comment);
}
});
}
}).onDone(() {
emit(state.copyWith(
downloadStatus: StoriesDownloadStatus.finished,
));
});
} catch (_) {
emit(state.copyWith(
downloadStatus: StoriesDownloadStatus.failure,
));
}
}
Future<void> onExitOffline(
StoriesExitOffline event, Emitter<StoriesState> emit) async {
await _cacheRepository.deleteAllStoryIds();
await _cacheRepository.deleteAllStories();
await _cacheRepository.deleteAllComments();
emit(state.copyWith(offlineReading: false));
add(StoriesInitialize());
}
}

View File

@ -37,6 +37,16 @@ class StoriesLoadMore extends StoriesEvent {
List<Object?> get props => [type];
}
class StoriesDownload extends StoriesEvent {
@override
List<Object?> get props => [];
}
class StoriesExitOffline extends StoriesEvent {
@override
List<Object?> get props => [];
}
class StoryLoaded extends StoriesEvent {
StoryLoaded({required this.story, required this.type});

View File

@ -1,6 +1,17 @@
part of 'stories_bloc.dart';
enum StoriesStatus { loading, loaded }
enum StoriesStatus {
initial,
loading,
loaded,
}
enum StoriesDownloadStatus {
initial,
downloading,
finished,
failure,
}
class StoriesState extends Equatable {
const StoriesState({
@ -8,6 +19,8 @@ class StoriesState extends Equatable {
required this.storyIdsByType,
required this.statusByType,
required this.currentPageByType,
required this.offlineReading,
required this.downloadStatus,
});
const StoriesState.init({
@ -26,11 +39,11 @@ class StoriesState extends Equatable {
StoryType.jobs: [],
},
this.statusByType = const <StoryType, StoriesStatus>{
StoryType.top: StoriesStatus.loaded,
StoryType.latest: StoriesStatus.loaded,
StoryType.ask: StoriesStatus.loaded,
StoryType.show: StoriesStatus.loaded,
StoryType.jobs: StoriesStatus.loaded,
StoryType.top: StoriesStatus.initial,
StoryType.latest: StoriesStatus.initial,
StoryType.ask: StoriesStatus.initial,
StoryType.show: StoriesStatus.initial,
StoryType.jobs: StoriesStatus.initial,
},
this.currentPageByType = const <StoryType, int>{
StoryType.top: 0,
@ -39,24 +52,31 @@ class StoriesState extends Equatable {
StoryType.show: 0,
StoryType.jobs: 0,
},
});
}) : offlineReading = false,
downloadStatus = StoriesDownloadStatus.initial;
final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType;
final Map<StoryType, StoriesStatus> statusByType;
final Map<StoryType, int> currentPageByType;
final StoriesDownloadStatus downloadStatus;
final bool offlineReading;
StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType,
Map<StoryType, List<int>>? storyIdsByType,
Map<StoryType, StoriesStatus>? statusByType,
Map<StoryType, int>? currentPageByType,
StoriesDownloadStatus? downloadStatus,
bool? offlineReading,
}) {
return StoriesState(
storiesByType: storiesByType ?? this.storiesByType,
storyIdsByType: storyIdsByType ?? this.storyIdsByType,
statusByType: statusByType ?? this.statusByType,
currentPageByType: currentPageByType ?? this.currentPageByType,
offlineReading: offlineReading ?? this.offlineReading,
downloadStatus: downloadStatus ?? this.downloadStatus,
);
}
@ -71,6 +91,8 @@ class StoriesState extends Equatable {
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: currentPageByType,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -85,6 +107,8 @@ class StoriesState extends Equatable {
storyIdsByType: newMap,
statusByType: statusByType,
currentPageByType: currentPageByType,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -99,6 +123,8 @@ class StoriesState extends Equatable {
storyIdsByType: storyIdsByType,
statusByType: newMap,
currentPageByType: currentPageByType,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -113,6 +139,8 @@ class StoriesState extends Equatable {
storyIdsByType: storyIdsByType,
statusByType: statusByType,
currentPageByType: newMap,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -130,6 +158,8 @@ class StoriesState extends Equatable {
storyIdsByType: newStoryIdsMap,
statusByType: newStatusMap,
currentPageByType: newCurrentPageMap,
offlineReading: offlineReading,
downloadStatus: downloadStatus,
);
}
@ -139,5 +169,7 @@ class StoriesState extends Equatable {
storyIdsByType,
statusByType,
currentPageByType,
offlineReading,
downloadStatus,
];
}

View File

@ -1,8 +1,14 @@
class Constants {
abstract class Constants {
static const String endUserAgreementLink =
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
static const String portfolioLink = 'https://livinglist.github.io';
static const String githubLink = 'https://github.com/Livinglist/Hacki';
static const String appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
static const String googlePlayLink =
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
static const String hackerNewsLogoPath = 'images/hacker_news_logo.png';
static const String hackiIconPath = 'images/hacki_icon.png';
@ -11,4 +17,5 @@ class Constants {
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
}

View File

@ -22,8 +22,10 @@ class CustomRouter {
/// Nested routing for bottom navigation bar.
static Route onGenerateNestedRoute(RouteSettings settings) {
switch (settings.name) {
case HomeScreen.routeName:
return HomeScreen.route();
case StoryScreen.routeName:
return StoryScreen.route(settings.arguments! as StoryScreenArgs);
case SubmitScreen.routeName:
return SubmitScreen.route();
default:
return _errorRoute();
}

View File

@ -14,5 +14,6 @@ Future<void> setUpLocator() async {
..registerSingleton<AuthRepository>(AuthRepository())
..registerSingleton<PostRepository>(PostRepository())
..registerSingleton<SembastRepository>(SembastRepository())
..registerSingleton<CacheRepository>(CacheRepository())
..registerSingleton<CacheService>(CacheService());
}

32
lib/cubits/cache/cache_cubit.dart vendored Normal file
View File

@ -0,0 +1,32 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/repositories/repositories.dart';
part 'cache_state.dart';
class CacheCubit extends Cubit<CacheState> {
CacheCubit({CacheRepository? cacheRepository})
: _cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
super(CacheState.init()) {
init();
}
final CacheRepository _cacheRepository;
void init() {
_cacheRepository.getAllReadStoriesIds().then((allReadStories) {
emit(state.copyWith(ids: allReadStories));
});
}
void markStoryAsRead(int id) {
emit(state.copyWithStoryMarkedAsRead(id: id));
_cacheRepository.cacheReadStoryId(id: id);
}
void deleteAllReadStoryIds() {
emit(CacheState.init());
_cacheRepository.deleteAllReadStoryIds();
}
}

30
lib/cubits/cache/cache_state.dart vendored Normal file
View File

@ -0,0 +1,30 @@
part of 'cache_cubit.dart';
class CacheState extends Equatable {
const CacheState({required this.storiesReadStatus});
CacheState.init() : storiesReadStatus = {};
final Map<int, bool> storiesReadStatus;
CacheState copyWith({required List<int> ids}) {
return CacheState(
storiesReadStatus: {
...storiesReadStatus,
...Map<int, bool>.fromEntries(
ids.map((e) => MapEntry<int, bool>(e, true)))
},
);
}
CacheState copyWithStoryMarkedAsRead({required int id}) {
return CacheState(storiesReadStatus: {...storiesReadStatus, id: true});
}
CacheState copyWithStoryMarkedAsUnread({required int id}) {
return CacheState(storiesReadStatus: {...storiesReadStatus, id: false});
}
@override
List<Object?> get props => [storiesReadStatus];
}

View File

@ -3,29 +3,53 @@ import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/cache_service.dart';
import 'package:hacki/services/services.dart';
part 'comments_state.dart';
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
CommentsCubit(
{required T item,
CacheService? cacheService,
StoriesRepository? storiesRepository})
: _cacheService = cacheService ?? locator.get<CacheService>(),
CommentsCubit({
CacheService? cacheService,
CacheRepository? cacheRepository,
StoriesRepository? storiesRepository,
required bool offlineReading,
required T item,
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
_cacheRepository = cacheRepository ?? locator.get<CacheRepository>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(CommentsState.init()) {
init(item);
}
super(CommentsState.init(offlineReading: offlineReading, item: item));
final CacheService _cacheService;
final CacheRepository _cacheRepository;
final StoriesRepository _storiesRepository;
Future<void> init(T item) async {
if (item is Story) {
final story = item;
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
@override
void emit(CommentsState state) {
if (!isClosed) {
super.emit(state);
}
}
Future<void> init({
bool onlyShowTargetComment = false,
Comment? targetComment,
}) async {
if (onlyShowTargetComment) {
emit(state.copyWith(
comments: targetComment != null ? [targetComment] : [],
onlyShowTargetComment: true,
));
return;
}
emit(state.copyWith(status: CommentsStatus.loading));
if (state.item is Story) {
final story = state.item;
final updatedStory = state.offlineReading
? story
: await _storiesRepository.fetchStoryBy(story.id);
emit(state.copyWith(item: updatedStory));
@ -35,9 +59,15 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
comments: List.from(state.comments)..add(cachedComment)));
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
if (state.offlineReading) {
await _cacheRepository
.getCachedComment(id: id)
.then(_onCommentFetched);
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
}
}
}
@ -45,11 +75,10 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
status: CommentsStatus.loaded,
));
} else {
final comment = item;
final comment = state.item as Comment;
emit(state.copyWith(
item: item,
collapsed: _cacheService.isCollapsed(item.id),
collapsed: _cacheService.isCollapsed(state.item.id),
));
for (final id in comment.kids) {
@ -58,9 +87,15 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
comments: List.from(state.comments)..add(cachedComment)));
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
if (state.offlineReading) {
await _cacheRepository
.getCachedComment(id: id)
.then(_onCommentFetched);
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
}
}
}
@ -71,10 +106,22 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
}
Future<void> refresh() async {
emit(state.copyWith(status: CommentsStatus.loading, comments: []));
final offlineReading = await _cacheRepository.hasCachedStories;
if (offlineReading) {
emit(state.copyWith(
status: CommentsStatus.loaded,
));
return;
}
emit(state.copyWith(
status: CommentsStatus.loading,
comments: [],
));
final story = (state.item as Story?)!;
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
final updatedStory = await _storiesRepository.fetchStoryBy(story.id);
for (final id in updatedStory.kids) {
final cachedComment = _cacheService.getComment(id);
@ -82,7 +129,17 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
emit(state.copyWith(
comments: List.from(state.comments)..add(cachedComment)));
} else {
await _storiesRepository.fetchCommentBy(id: id).then(_onCommentFetched);
final offlineReading = await _cacheRepository.hasCachedStories;
if (offlineReading) {
await _cacheRepository
.getCachedComment(id: id)
.then(_onCommentFetched);
} else {
await _storiesRepository
.fetchCommentBy(id: id)
.then(_onCommentFetched);
}
}
}
@ -93,10 +150,19 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
}
void collapse() {
_cacheService.updateCollapsedComments(state.item!.id);
_cacheService.updateCollapsedComments(state.item.id);
emit(state.copyWith(collapsed: !state.collapsed));
}
void loadAll(T item) {
emit(state.copyWith(
onlyShowTargetComment: false,
comments: [],
item: item,
));
init();
}
void _onCommentFetched(Comment? comment) {
if (comment != null) {
_cacheService.cacheComment(comment);

View File

@ -13,30 +13,41 @@ class CommentsState extends Equatable {
required this.comments,
required this.status,
required this.collapsed,
required this.onlyShowTargetComment,
required this.offlineReading,
});
CommentsState.init()
: item = null,
comments = [],
CommentsState.init({
required this.offlineReading,
required this.item,
}) : comments = [],
status = CommentsStatus.init,
collapsed = false;
collapsed = false,
onlyShowTargetComment = false;
final Item? item;
final Item item;
final List<Comment> comments;
final CommentsStatus status;
final bool collapsed;
final bool onlyShowTargetComment;
final bool offlineReading;
CommentsState copyWith({
Item? item,
List<Comment>? comments,
CommentsStatus? status,
bool? collapsed,
bool? onlyShowTargetComment,
bool? offlineReading,
}) {
return CommentsState(
item: item ?? this.item,
comments: comments ?? this.comments,
status: status ?? this.status,
collapsed: collapsed ?? this.collapsed,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading,
);
}
@ -46,5 +57,7 @@ class CommentsState extends Equatable {
comments,
status,
collapsed,
onlyShowTargetComment,
offlineReading,
];
}

View File

@ -1,4 +1,5 @@
export 'blocklist/blocklist_cubit.dart';
export 'cache/cache_cubit.dart';
export 'comments/comments_cubit.dart';
export 'edit/edit_cubit.dart';
export 'fav/fav_cubit.dart';
@ -8,5 +9,6 @@ export 'pin/pin_cubit.dart';
export 'post/post_cubit.dart';
export 'preference/preference_cubit.dart';
export 'search/search_cubit.dart';
export 'split_view/split_view_cubit.dart';
export 'submit/submit_cubit.dart';
export 'vote/vote_cubit.dart';

View File

@ -16,13 +16,20 @@ class EditCubit extends Cubit<EditState> {
final CacheService _cacheService;
final Debouncer _debouncer;
void onItemTapped(Item item) {
void onReplyTapped(Item item) {
emit(EditState(
replyingTo: item,
text: _cacheService.getDraft(replyingTo: item.id),
));
}
void onEditTapped(Item itemToBeEdited) {
emit(EditState(
itemBeingEdited: itemToBeEdited,
text: itemToBeEdited.text,
));
}
void onReplyBoxClosed() {
emit(const EditState.init());
}

View File

@ -4,20 +4,24 @@ class EditState extends Equatable {
const EditState({
this.text,
this.replyingTo,
this.itemBeingEdited,
});
const EditState.init()
: text = null,
replyingTo = null;
replyingTo = null,
itemBeingEdited = null;
final String? text;
final Item? replyingTo;
final Item? itemBeingEdited;
bool get showReplyBox => replyingTo != null;
bool get showReplyBox => replyingTo != null || itemBeingEdited != null;
EditState copyWith({String? text}) {
return EditState(
replyingTo: replyingTo,
itemBeingEdited: itemBeingEdited,
text: text ?? this.text,
);
}
@ -26,5 +30,6 @@ class EditState extends Equatable {
List<Object?> get props => [
text,
replyingTo,
itemBeingEdited,
];
}

View File

@ -66,7 +66,7 @@ class FavCubit extends Cubit<FavState> {
),
);
final story = await _storiesRepository.fetchStoryById(id);
final story = await _storiesRepository.fetchStoryBy(id);
emit(state.copyWith(
favStories: List<Story>.from(state.favStories)..insert(0, story)));

View File

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';

View File

@ -27,6 +27,17 @@ class PostCubit extends Cubit<PostState> {
}
}
Future<void> edit({required String text, required int id}) async {
emit(state.copyWith(status: PostStatus.loading));
final successful = await _postRepository.edit(id: id, text: text);
if (successful) {
emit(state.copyWith(status: PostStatus.successful));
} else {
emit(state.copyWith(status: PostStatus.failure));
}
}
void reset() {
emit(state.copyWith(status: PostStatus.init));
}

View File

@ -22,14 +22,14 @@ class PreferenceCubit extends Cubit<PreferenceState> {
.then((value) => emit(state.copyWith(showComplexStoryTile: value)));
_storageRepository.shouldShowWebFirst
.then((value) => emit(state.copyWith(showWebFirst: value)));
_storageRepository.shouldCommentBorder
.then((value) => emit(state.copyWith(showCommentBorder: value)));
_storageRepository.shouldShowEyeCandy
.then((value) => emit(state.copyWith(showEyeCandy: value)));
_storageRepository.trueDarkMode
.then((value) => emit(state.copyWith(useTrueDark: value)));
_storageRepository.readerMode
.then((value) => emit(state.copyWith(useReader: value)));
_storageRepository.markReadStories
.then((value) => emit(state.copyWith(markReadStories: value)));
}
void toggleNotificationMode() {
@ -47,11 +47,6 @@ class PreferenceCubit extends Cubit<PreferenceState> {
_storageRepository.toggleNavigationMode();
}
void toggleCommentBorderMode() {
emit(state.copyWith(showCommentBorder: !state.showCommentBorder));
_storageRepository.toggleCommentBorderMode();
}
void toggleEyeCandyMode() {
emit(state.copyWith(showEyeCandy: !state.showEyeCandy));
_storageRepository.toggleEyeCandyMode();
@ -66,4 +61,9 @@ class PreferenceCubit extends Cubit<PreferenceState> {
emit(state.copyWith(useReader: !state.useReader));
_storageRepository.toggleReaderMode();
}
void toggleMarkReadStoriesMode() {
emit(state.copyWith(markReadStories: !state.markReadStories));
_storageRepository.toggleMarkReadStoriesMode();
}
}

View File

@ -5,46 +5,46 @@ class PreferenceState extends Equatable {
required this.showNotification,
required this.showComplexStoryTile,
required this.showWebFirst,
required this.showCommentBorder,
required this.showEyeCandy,
required this.useTrueDark,
required this.useReader,
required this.markReadStories,
});
const PreferenceState.init()
: showNotification = false,
showComplexStoryTile = false,
showWebFirst = false,
showCommentBorder = false,
showEyeCandy = false,
useTrueDark = false,
useReader = false;
useReader = false,
markReadStories = false;
final bool showNotification;
final bool showComplexStoryTile;
final bool showWebFirst;
final bool showCommentBorder;
final bool showEyeCandy;
final bool useTrueDark;
final bool useReader;
final bool markReadStories;
PreferenceState copyWith({
bool? showNotification,
bool? showComplexStoryTile,
bool? showWebFirst,
bool? showCommentBorder,
bool? showEyeCandy,
bool? useTrueDark,
bool? useReader,
bool? markReadStories,
}) {
return PreferenceState(
showNotification: showNotification ?? this.showNotification,
showComplexStoryTile: showComplexStoryTile ?? this.showComplexStoryTile,
showWebFirst: showWebFirst ?? this.showWebFirst,
showCommentBorder: showCommentBorder ?? this.showCommentBorder,
showEyeCandy: showEyeCandy ?? this.showEyeCandy,
useTrueDark: useTrueDark ?? this.useTrueDark,
useReader: useReader ?? this.useReader,
markReadStories: markReadStories ?? this.markReadStories,
);
}
@ -53,9 +53,9 @@ class PreferenceState extends Equatable {
showNotification,
showComplexStoryTile,
showWebFirst,
showCommentBorder,
showEyeCandy,
useTrueDark,
useReader,
markReadStories,
];
}

View File

@ -0,0 +1,17 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/screens/screens.dart';
part 'split_view_state.dart';
class SplitViewCubit extends Cubit<SplitViewState> {
SplitViewCubit() : super(const SplitViewState.init());
void updateStoryScreenArgs(StoryScreenArgs args) {
emit(state.copyWith(storyScreenArgs: args));
}
void enableSplitView() => emit(state.copyWith(enabled: true));
void disableSplitView() => emit(state.copyWith(enabled: false));
}

View File

@ -0,0 +1,28 @@
part of 'split_view_cubit.dart';
class SplitViewState extends Equatable {
const SplitViewState({
required this.storyScreenArgs,
required this.enabled,
});
const SplitViewState.init()
: enabled = false,
storyScreenArgs = null;
final bool enabled;
final StoryScreenArgs? storyScreenArgs;
SplitViewState copyWith({bool? enabled, StoryScreenArgs? storyScreenArgs}) {
return SplitViewState(
enabled: enabled ?? this.enabled,
storyScreenArgs: storyScreenArgs ?? this.storyScreenArgs,
);
}
@override
List<Object?> get props => [
enabled,
storyScreenArgs,
];
}

View File

@ -6,7 +6,12 @@ extension DateTimeExtension on DateTime {
final gap = now.year - year;
return '$gap year${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays > 30) {
final gap = (now.month - month).clamp(1, 12);
var gap = now.month - month;
if (gap == 0) {
gap = 1;
} else if (gap < 0) {
gap = now.month + (12 - month);
}
return '$gap month${gap == 1 ? '' : 's'} ago';
} else if (diff.inDays >= 1) {
if (diff.inHours <= 24) {

View File

@ -1,2 +1,4 @@
export 'date_time_extension.dart';
export 'object_extension.dart';
export 'state_extension.dart';
export 'widget_extension.dart';

View File

@ -0,0 +1,7 @@
import 'dart:developer' as dev;
extension ObjectExtension on Object {
void log({String identifier = ''}) {
dev.log('$identifier ${toString()}');
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart';
import 'package:hacki/screens/screens.dart' show StoryScreen, StoryScreenArgs;
extension StateExtension on State {
void showSnackBar({
required String content,
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Colors.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyText1?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
Future<void>? goToStoryScreen({required StoryScreenArgs args}) {
final splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateStoryScreenArgs(args);
} else {
return HackiApp.navigatorKey.currentState?.pushNamed(
StoryScreen.routeName,
arguments: args,
);
}
return Future.value();
}
}

View File

@ -7,10 +7,16 @@ 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:hive/hive.dart';
import 'package:path_provider/path_provider.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
final tempDir = await getTemporaryDirectory();
final tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator();
final savedThemeMode = await AdaptiveTheme.getThemeMode();
@ -77,6 +83,14 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (context) => PinCubit(),
),
BlocProvider<CacheCubit>(
lazy: false,
create: (context) => CacheCubit(),
),
BlocProvider<SplitViewCubit>(
lazy: false,
create: (context) => SplitViewCubit(),
),
],
child: AdaptiveTheme(
light: ThemeData(

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart';
@ -6,6 +8,7 @@ class Comment extends Item {
required int id,
required int time,
required int parent,
required int score,
required String by,
required String text,
required List<int> kids,
@ -18,7 +21,7 @@ class Comment extends Item {
kids: kids,
parent: parent,
deleted: deleted,
score: 0,
score: score,
descendants: 0,
dead: false,
parts: [],
@ -37,7 +40,7 @@ class Comment extends Item {
kids: (json['kids'] as List?)?.cast<int>() ?? [],
parent: json['parent'] as int? ?? 0,
deleted: json['deleted'] as bool? ?? false,
score: 0,
score: json['score'] as int? ?? 0,
descendants: 0,
dead: json['dead'] as bool? ?? false,
parts: [],
@ -59,8 +62,15 @@ class Comment extends Item {
'parent': parent,
'deleted': deleted,
'dead': dead,
'score': score,
};
@override
String toString() {
final prettyString = const JsonEncoder.withIndent(' ').convert(this);
return 'Comment $prettyString';
}
@override
List<Object?> get props => [
id,

View File

@ -146,6 +146,27 @@ class SubmitPostData with PostDataMixin {
}
}
class EditPostData with PostDataMixin {
EditPostData({
required this.hmac,
required this.id,
this.text,
});
final String hmac;
final int id;
final String? text;
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'hmac': hmac,
'id': id,
'text': text,
};
}
}
class FormPostData with PostDataMixin {
FormPostData({
required this.acct,

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart';
@ -79,6 +81,29 @@ class Story extends Item {
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
};
}
@override
String toString() {
final prettyString = const JsonEncoder.withIndent(' ').convert(this);
return 'Story $prettyString';
}
@override
List<Object?> get props => [
id,

View File

@ -1,3 +1,5 @@
import 'dart:convert';
class User {
User({
required this.about,
@ -26,4 +28,10 @@ class User {
final int delay;
final String id;
final int karma;
@override
String toString() {
final prettyString = const JsonEncoder.withIndent(' ').convert(this);
return 'User $prettyString';
}
}

View File

@ -0,0 +1,124 @@
import 'package:hacki/models/models.dart';
import 'package:hive/hive.dart';
class CacheRepository {
CacheRepository({
Future<Box<bool>>? readStoryIdBox,
Future<Box<List<int>>>? storyIdBox,
Future<Box<Map<dynamic, dynamic>>>? storyBox,
Future<LazyBox<Map<dynamic, dynamic>>>? commentBox,
}) : _readStoryIdBox =
readStoryIdBox ?? Hive.openBox<bool>(_readStoryIdBoxName),
_storyIdBox = storyIdBox ?? Hive.openBox<List<int>>(_storyIdBoxName),
_storyBox =
storyBox ?? Hive.openBox<Map<dynamic, dynamic>>(_storyBoxName),
_commentBox = commentBox ??
Hive.openLazyBox<Map<dynamic, dynamic>>(_commentBoxName);
static const _readStoryIdBoxName = 'readStoryIdBox';
static const _storyIdBoxName = 'storyIdBox';
static const _storyBoxName = 'storyBox';
static const _commentBoxName = 'commentBox';
final Future<Box<bool>> _readStoryIdBox;
final Future<Box<List<int>>> _storyIdBox;
final Future<Box<Map<dynamic, dynamic>>> _storyBox;
final Future<LazyBox<Map<dynamic, dynamic>>> _commentBox;
Future<bool> get hasCachedStories => _storyBox.then((box) => box.isNotEmpty);
Future<bool> wasRead({required int id}) async {
final box = await _readStoryIdBox;
final val = box.get(id.toString());
return val != null;
}
Future<void> cacheReadStoryId({required int id}) async {
final box = await _readStoryIdBox;
return box.put(id.toString(), true);
}
Future<List<int>> getAllReadStoriesIds() async {
final box = await _readStoryIdBox;
final allReads = box.keys.cast<String>().map(int.parse).toList();
return allReads;
}
Future<void> cacheStoryIds(
{required StoryType of, required List<int> ids}) async {
final box = await _storyIdBox;
return box.put(of.name, ids);
}
Future<void> cacheStory({required Story story}) async {
final box = await _storyBox;
return box.put(story.id.toString(), story.toJson());
}
Future<List<int>> getCachedStoryIds({required StoryType of}) async {
final box = await _storyIdBox;
final ids = box.get(of.name);
return ids ?? [];
}
Stream<Story> getCachedStoriesStream({required List<int> ids}) async* {
final box = await _storyBox;
for (final id in ids) {
final json = box.get(id.toString());
if (json == null) {
continue;
}
final story = Story.fromJson(json.cast<String, dynamic>());
yield story;
}
return;
}
Future<Story?> getCachedStory({required int id}) async {
final box = await _storyBox;
final json = box.get(id.toString());
if (json == null) {
return null;
}
final story = Story.fromJson(json.cast<String, dynamic>());
return story;
}
Future<void> cacheComment({required Comment comment}) async {
final box = await _commentBox;
return box.put(comment.id.toString(), comment.toJson());
}
Future<Comment?> getCachedComment({required int id}) async {
final box = await _commentBox;
final json = await box.get(id.toString());
if (json == null) {
return null;
}
final comment = Comment.fromJson(json.cast<String, dynamic>());
return comment;
}
Future<int> deleteAllReadStoryIds() async {
final box = await _readStoryIdBox;
return box.clear();
}
Future<int> deleteAllStoryIds() async {
final box = await _storyIdBox;
return box.clear();
}
Future<int> deleteAllStories() async {
final box = await _storyBox;
return box.clear();
}
Future<int> deleteAllComments() async {
final box = await _commentBox;
return box.clear();
}
}

View File

@ -84,6 +84,45 @@ class PostRepository {
);
}
Future<bool> edit({
required int id,
String? text,
}) async {
final username = await _storageRepository.username;
final password = await _storageRepository.password;
if (username == null || password == null) {
return false;
}
final formResponse = await _getFormResponse(
username: username,
password: password,
id: id,
path: 'edit',
);
final formValues = HtmlUtil.getHiddenFormValues(formResponse.data);
if (formValues == null || formValues.isEmpty) {
return false;
}
final cookie = formResponse.headers.value(HttpHeaders.setCookieHeader);
final uri = Uri.https(authority, 'xedit');
final PostDataMixin data = EditPostData(
hmac: formValues['hmac']!,
id: id,
text: text,
);
return _performDefaultPost(
uri,
data,
cookie: cookie,
);
}
Future<Response<List<int>>> _getFormResponse({
required String username,
required String password,

View File

@ -1,4 +1,5 @@
export 'auth_repository.dart';
export 'cache_repository.dart';
export 'post_repository.dart';
export 'search_repository.dart';
export 'sembast_repository.dart';

View File

@ -12,6 +12,8 @@ class StorageRepository {
static const String _passwordKey = 'password';
static const String _blocklistKey = 'blocklist';
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _notificationModeKey = 'notificationMode';
static const String _trueDarkModeKey = 'trueDarkMode';
static const String _readerModeKey = 'readerMode';
@ -23,18 +25,16 @@ class StorageRepository {
/// The key of a boolean value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
static const String _navigationModeKey = 'navigationMode';
static const String _commentBorderModeKey = 'commentBorderMode';
static const String _eyeCandyModeKey = 'eyeCandyMode';
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
static const String _markReadStoriesModeKey = 'markReadStoriesMode';
static const bool _notificationModeDefaultValue = true;
static const bool _displayModeDefaultValue = true;
static const bool _navigationModeDefaultValue = true;
static const bool _commentBorderModeDefaultValue = true;
static const bool _eyeCandyModeDefaultValue = false;
static const bool _trueDarkModeDefaultValue = false;
static const bool _readerModeKeyDefaultValue = true;
static const bool _readerModeDefaultValue = true;
static const bool _markReadStoriesModeDefaultValue = true;
final Future<SharedPreferences> _prefs;
final FlutterSecureStorage _secureStorage;
@ -60,9 +60,6 @@ class StorageRepository {
Future<bool> get shouldShowWebFirst async => _prefs.then((prefs) =>
prefs.getBool(_navigationModeKey) ?? _navigationModeDefaultValue);
Future<bool> get shouldCommentBorder async => _prefs.then((prefs) =>
prefs.getBool(_commentBorderModeKey) ?? _commentBorderModeDefaultValue);
Future<bool> get shouldShowEyeCandy async => _prefs.then(
(prefs) => prefs.getBool(_eyeCandyModeKey) ?? _eyeCandyModeDefaultValue);
@ -70,7 +67,11 @@ class StorageRepository {
(prefs) => prefs.getBool(_trueDarkModeKey) ?? _trueDarkModeDefaultValue);
Future<bool> get readerMode async => _prefs.then(
(prefs) => prefs.getBool(_readerModeKey) ?? _readerModeKeyDefaultValue);
(prefs) => prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue);
Future<bool> get markReadStories async => _prefs.then((prefs) =>
prefs.getBool(_markReadStoriesModeKey) ??
_markReadStoriesModeDefaultValue);
Future<List<int>> get unreadCommentsIds async => _prefs.then((prefs) =>
prefs.getStringList(_unreadCommentsIdsKey)?.map(int.parse).toList() ??
@ -124,13 +125,6 @@ class StorageRepository {
await prefs.setBool(_navigationModeKey, !currentMode);
}
Future<void> toggleCommentBorderMode() async {
final prefs = await _prefs;
final currentMode =
prefs.getBool(_commentBorderModeKey) ?? _commentBorderModeDefaultValue;
await prefs.setBool(_commentBorderModeKey, !currentMode);
}
Future<void> toggleEyeCandyMode() async {
final prefs = await _prefs;
final currentMode =
@ -148,10 +142,17 @@ class StorageRepository {
Future<void> toggleReaderMode() async {
final prefs = await _prefs;
final currentMode =
prefs.getBool(_readerModeKey) ?? _readerModeKeyDefaultValue;
prefs.getBool(_readerModeKey) ?? _readerModeDefaultValue;
await prefs.setBool(_readerModeKey, !currentMode);
}
Future<void> toggleMarkReadStoriesMode() async {
final prefs = await _prefs;
final currentMode = prefs.getBool(_markReadStoriesModeKey) ??
_markReadStoriesModeDefaultValue;
await prefs.setBool(_markReadStoriesModeKey, !currentMode);
}
Future<void> addFav({required String username, required int id}) async {
final prefs = await _prefs;
final key = _getFavKey(username);

View File

@ -1,7 +1,8 @@
import 'package:firebase/firebase_io.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:tuple/tuple.dart';
class StoriesRepository {
StoriesRepository({
@ -11,7 +12,7 @@ class StoriesRepository {
final FirebaseClient _firebaseClient;
static const _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
Future<User> fetchUserById({required String userId}) async {
Future<User> fetchUserBy({required String userId}) async {
final user = await _firebaseClient
.get('${_baseUrl}user/$userId.json')
.then((dynamic val) {
@ -47,7 +48,7 @@ class StoriesRepository {
return ids;
}
Future<Story> fetchStoryById(int id) async {
Future<Story> fetchStoryBy(int id) async {
final story = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
@ -128,7 +129,7 @@ class StoriesRepository {
Future<Item?> fetchItemBy({required int id}) async {
final item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
.then((dynamic val) async {
if (val == null) {
return null;
}
@ -138,6 +139,9 @@ class StoriesRepository {
final story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
final text = json['text'] as String? ?? '';
final parsedText = await compute<String, String>(_parseHtml, text);
json['text'] = parsedText;
final comment = Comment.fromJson(json);
return comment;
}
@ -172,6 +176,32 @@ class StoriesRepository {
return item as Story;
}
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments(
{required int id}) async {
Item? item;
final parentComments = <Comment>[];
do {
item = await fetchItemBy(id: item?.parent ?? id);
if (item is Comment) {
parentComments.add(item);
}
if (item == null) return null;
} while (item is Comment);
return Tuple2<Story, List<Comment>>(item as Story, parentComments);
}
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
for (final id in ids) {
final comment = await fetchCommentBy(id: id);
if (comment != null) {
yield comment;
yield* fetchAllChildrenComments(ids: comment.kids);
}
}
}
static String _parseHtml(String text) {
return HtmlUnescape()
.convert(text)

View File

@ -1,3 +1,5 @@
// ignore_for_file: lines_longer_than_80_chars
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
@ -16,12 +18,12 @@ import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:responsive_builder/responsive_builder.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
static const String routeName = '/home';
static const String routeName = '/';
static Route route() {
return MaterialPageRoute<HomeScreen>(
@ -37,11 +39,6 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
final cacheService = locator.get<CacheService>();
final refreshControllerTop = RefreshController();
final refreshControllerNew = RefreshController();
final refreshControllerAsk = RefreshController();
final refreshControllerShow = RefreshController();
final refreshControllerJobs = RefreshController();
late final TabController tabController;
int currentIndex = 0;
@ -54,6 +51,7 @@ class _HomeScreenState extends State<HomeScreen>
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
SchedulerBinding.instance?.addPostFrameCallback((_) {
@ -75,7 +73,9 @@ class _HomeScreenState extends State<HomeScreen>
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
final homeScreen = BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (previous, current) =>
previous.showComplexStoryTile != current.showComplexStoryTile,
builder: (context, preferenceState) {
final pinnedStories = BlocBuilder<PinCubit, PinState>(
builder: (context, state) {
@ -88,8 +88,10 @@ class _HomeScreenState extends State<HomeScreen>
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) =>
context.read<PinCubit>().unpinStory(story),
onPressed: (_) {
HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: preferenceState.showComplexStoryTile
@ -122,58 +124,41 @@ class _HomeScreenState extends State<HomeScreen>
},
);
return BlocConsumer<StoriesBloc, StoriesState>(
listener: (context, state) {
if (state.statusByType[StoryType.top] == StoriesStatus.loaded) {
refreshControllerTop
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.latest] == StoriesStatus.loaded) {
refreshControllerNew
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.ask] == StoriesStatus.loaded) {
refreshControllerAsk
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.show] == StoriesStatus.loaded) {
refreshControllerShow
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
if (state.statusByType[StoryType.jobs] == StoriesStatus.loaded) {
refreshControllerJobs
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
},
builder: (context, state) {
return WillPopScope(
onWillPop: () => Future.value(false),
child: DefaultTabController(
length: 6,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(0, 48),
child: Column(
children: [
SizedBox(
height: MediaQuery.of(context).padding.top,
return BlocBuilder<CacheCubit, CacheState>(
builder: (context, cacheState) {
return DefaultTabController(
length: 6,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(0, 40),
child: Column(
children: [
SizedBox(
height: MediaQuery.of(context).padding.top - 8,
),
Theme(
data: ThemeData(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
primaryColor: Theme.of(context).primaryColor,
),
TabBar(
child: TabBar(
isScrollable: true,
controller: tabController,
indicatorColor: Colors.orange,
indicator: CircleTabIndicator(
color: Colors.orange, radius: 2),
indicatorPadding: const EdgeInsets.only(bottom: 8),
onTap: (_) {
HapticFeedback.selectionClick();
},
tabs: [
Tab(
child: Text(
'TOP',
style: TextStyle(
fontSize: 14,
fontSize: currentIndex == 0 ? 14 : 10,
color: currentIndex == 0
? Colors.orange
: Colors.grey,
@ -184,7 +169,7 @@ class _HomeScreenState extends State<HomeScreen>
child: Text(
'NEW',
style: TextStyle(
fontSize: 14,
fontSize: currentIndex == 1 ? 14 : 10,
color: currentIndex == 1
? Colors.orange
: Colors.grey,
@ -195,7 +180,7 @@ class _HomeScreenState extends State<HomeScreen>
child: Text(
'ASK',
style: TextStyle(
fontSize: 14,
fontSize: currentIndex == 2 ? 14 : 10,
color: currentIndex == 2
? Colors.orange
: Colors.grey,
@ -206,7 +191,7 @@ class _HomeScreenState extends State<HomeScreen>
child: Text(
'SHOW',
style: TextStyle(
fontSize: 13,
fontSize: currentIndex == 3 ? 14 : 10,
color: currentIndex == 3
? Colors.orange
: Colors.grey,
@ -217,7 +202,7 @@ class _HomeScreenState extends State<HomeScreen>
child: Text(
'JOBS',
style: TextStyle(
fontSize: 14,
fontSize: currentIndex == 4 ? 14 : 10,
color: currentIndex == 4
? Colors.orange
: Colors.grey,
@ -226,6 +211,8 @@ class _HomeScreenState extends State<HomeScreen>
),
Tab(
child: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.person,
@ -239,184 +226,173 @@ class _HomeScreenState extends State<HomeScreen>
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.\n\nAlso, you can '
'long press here to submit a new link to '
'Hacker News.',
'your comments or stories.',
style: TextStyle(fontSize: 16),
),
child: BlocBuilder<NotificationCubit,
NotificationState>(
buildWhen: (previous, current) =>
previous.unreadCommentsIds.length !=
current.unreadCommentsIds.length,
builder: (context, state) {
if (state.unreadCommentsIds.isEmpty) {
return Icon(
return Badge(
showBadge:
state.unreadCommentsIds.isNotEmpty,
borderRadius: BorderRadius.circular(100),
badgeContent: Container(
height: 3,
width: 3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white),
),
child: Icon(
Icons.person,
size: 16,
size: currentIndex == 5 ? 16 : 12,
color: currentIndex == 5
? Colors.orange
: Colors.grey,
);
} else {
return Badge(
borderRadius:
BorderRadius.circular(100),
badgeContent: Container(
height: 3,
width: 3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white),
),
child: Icon(
Icons.person,
size: 16,
color: currentIndex == 5
? Colors.orange
: Colors.grey,
),
);
}
),
);
},
),
),
),
],
),
],
),
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: tabController,
children: [
ItemsListView<Story>(
pinnable: true,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerTop,
items: state.storiesByType[StoryType.top]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.top));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.top));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerNew,
items: state.storiesByType[StoryType.latest]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.latest));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.latest));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerAsk,
items: state.storiesByType[StoryType.ask]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.ask));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.ask));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerShow,
items: state.storiesByType[StoryType.show]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.show));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.show));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
ItemsListView<Story>(
pinnable: true,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshControllerJobs,
items: state.storiesByType[StoryType.jobs]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: StoryType.jobs));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: StoryType.jobs));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: pinnedStories,
),
const ProfileScreen(),
],
),
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: tabController,
children: [
StoriesListView(
key: const ValueKey(StoryType.top),
storyType: StoryType.top,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
StoriesListView(
key: const ValueKey(StoryType.latest),
storyType: StoryType.latest,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
StoriesListView(
key: const ValueKey(StoryType.ask),
storyType: StoryType.ask,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
StoriesListView(
key: const ValueKey(StoryType.show),
storyType: StoryType.show,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
StoriesListView(
key: const ValueKey(StoryType.jobs),
storyType: StoryType.jobs,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
const ProfileScreen(),
],
),
),
);
},
);
},
);
return ScreenTypeLayout.builder(
mobile: (context) {
context.read<SplitViewCubit>().disableSplitView();
return homeScreen;
},
tablet: (context) {
return ResponsiveBuilder(
builder: (context, sizeInfo) {
context.read<SplitViewCubit>().enableSplitView();
var homeScreenWidth = 428.0;
if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
homeScreenWidth = 345.0;
}
return Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
width: homeScreenWidth,
child: homeScreen,
),
Positioned(
right: 0,
top: 0,
bottom: 0,
left: homeScreenWidth,
child: BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (previous, current) =>
previous.storyScreenArgs != current.storyScreenArgs,
builder: (context, state) {
if (state.storyScreenArgs != null) {
return StoryScreen.build(state.storyScreenArgs!);
}
return Material(
child: Container(
color: Theme.of(context).canvasColor,
child: const Center(
child: Text('Tap on story tile to view comments.'),
),
),
);
},
),
),
],
);
},
);
},
);
}
void onStoryTapped(Story story) {
final showWebFirst = context.read<PreferenceCubit>().state.showWebFirst;
final useReader = context.read<PreferenceCubit>().state.useReader;
final offlineReading = context.read<StoriesBloc>().state.offlineReading;
final firstTimeReading = cacheService.isFirstTimeReading(story.id);
final splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting,
// it would be better to just navigate to the web page.
final isJobWithLink = story.type == 'job' && story.url.isNotEmpty;
if (!isJobWithLink) {
HackiApp.navigatorKey.currentState!.pushNamed(StoryScreen.routeName,
arguments: StoryScreenArgs(story: story));
final args = StoryScreenArgs(story: story);
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateStoryScreenArgs(args);
} else {
HackiApp.navigatorKey.currentState?.pushNamed(
StoryScreen.routeName,
arguments: args,
);
}
}
if (isJobWithLink ||
(showWebFirst && cacheService.isFirstTimeReading(story.id))) {
if (!offlineReading &&
(isJobWithLink || (showWebFirst && firstTimeReading))) {
LinkUtil.launchUrl(story.url, useReader: useReader);
cacheService.store(story.id);
}
context.read<CacheCubit>().markStoryAsRead(story.id);
}
}

View File

@ -9,6 +9,7 @@ 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';
@ -16,10 +17,9 @@ import 'package:hacki/screens/profile/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/utils/utils.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
enum PageType {
enum _PageType {
fav,
history,
settings,
@ -40,8 +40,9 @@ class _ProfileScreenState extends State<ProfileScreen>
final refreshControllerFav = RefreshController();
final refreshControllerNotification = RefreshController();
final scrollController = ScrollController();
final throttle = Throttle(delay: const Duration(seconds: 2));
PageType pageType = PageType.notification;
_PageType pageType = _PageType.notification;
final magicWords = <String>[
'to be a lord.',
@ -52,6 +53,16 @@ class _ProfileScreenState extends State<ProfileScreen>
'to infinity and beyond!',
];
@override
void dispose() {
super.dispose();
refreshControllerHistory.dispose();
refreshControllerFav.dispose();
refreshControllerNotification.dispose();
scrollController.dispose();
throttle.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
@ -61,6 +72,8 @@ class _ProfileScreenState extends State<ProfileScreen>
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
return BlocConsumer<NotificationCubit, NotificationState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, notificationState) {
if (notificationState.status == NotificationStatus.loaded) {
refreshControllerNotification
@ -71,30 +84,10 @@ class _ProfileScreenState extends State<ProfileScreen>
builder: (context, notificationState) {
return Stack(
children: [
if (!authState.isLoggedIn && pageType == PageType.history)
Positioned.fill(
child: Column(
children: [
const SizedBox(
height: 120,
),
ElevatedButton(
onPressed: onLoginTapped,
style: ElevatedButton.styleFrom(
primary: Colors.deepOrange),
child: const Text(
'Log in',
style: TextStyle(color: Colors.white),
),
)
],
),
),
Positioned.fill(
top: 50,
child: Offstage(
offstage: !authState.isLoggedIn ||
pageType != PageType.history,
offstage: pageType != _PageType.history,
child: BlocConsumer<HistoryCubit, HistoryState>(
listener: (context, historyState) {
if (historyState.status == HistoryStatus.loaded) {
@ -104,8 +97,18 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
builder: (context, historyState) {
if ((!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) &&
historyState.status != HistoryStatus.loading) {
return const CenteredMessageView(
content: 'Your past comments and stories will '
'show up here.',
);
}
return ItemsListView<Item>(
showWebPreview: false,
useConsistentFontSize: true,
refreshController: refreshControllerHistory,
items: historyState.submittedItems
.where((e) => !e.dead && !e.deleted)
@ -119,21 +122,10 @@ class _ProfileScreenState extends State<ProfileScreen>
},
onTap: (item) {
if (item is Story) {
HackiApp.navigatorKey.currentState!.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(story: item));
goToStoryScreen(
args: StoryScreenArgs(story: item));
} else if (item is Comment) {
locator
.get<StoriesRepository>()
.fetchParentStory(id: item.parent)
.then((story) {
if (story != null && mounted) {
HackiApp.navigatorKey.currentState!
.pushNamed(StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story));
}
});
onCommentTapped(item);
}
},
);
@ -144,7 +136,7 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill(
top: 50,
child: Offstage(
offstage: pageType != PageType.fav,
offstage: pageType != _PageType.fav,
child: BlocConsumer<FavCubit, FavState>(
listener: (context, favState) {
if (favState.status == FavStatus.loaded) {
@ -154,6 +146,15 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
builder: (context, favState) {
if (favState.favStories.isEmpty &&
favState.status != FavStatus.loading) {
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 ItemsListView<Story>(
showWebPreview:
preferenceState.showComplexStoryTile,
@ -166,11 +167,8 @@ class _ProfileScreenState extends State<ProfileScreen>
onLoadMore: () {
context.read<FavCubit>().loadMore();
},
onTap: (story) {
HackiApp.navigatorKey.currentState!.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(story: story));
},
onTap: (story) => goToStoryScreen(
args: StoryScreenArgs(story: story)),
);
},
),
@ -179,54 +177,52 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill(
top: 50,
child: Offstage(
offstage: pageType != PageType.search,
offstage: pageType != _PageType.search,
child: const SearchScreen(),
),
),
Positioned.fill(
top: 50,
child: Offstage(
offstage: pageType != PageType.notification,
child: InboxView(
refreshController: refreshControllerNotification,
unreadCommentsIds:
notificationState.unreadCommentsIds,
comments: notificationState.comments,
onCommentTapped: (comment) {
locator
.get<StoriesRepository>()
.fetchParentStory(id: comment.parent)
.then((story) {
if (story != null && mounted) {
context
.read<NotificationCubit>()
.markAsRead(comment);
HackiApp.navigatorKey.currentState!.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story,
),
);
}
});
},
onMarkAllAsReadTapped: () {
context.read<NotificationCubit>().markAllAsRead();
},
onLoadMore: () {
context.read<NotificationCubit>().loadMore();
},
onRefresh: () {
HapticFeedback.lightImpact();
context.read<NotificationCubit>().refresh();
},
),
offstage: pageType != _PageType.notification,
child: notificationState.comments.isEmpty
? const CenteredMessageView(
content:
'New replies to your comments or stories '
'will show up here.',
)
: InboxView(
refreshController:
refreshControllerNotification,
unreadCommentsIds:
notificationState.unreadCommentsIds,
comments: notificationState.comments,
onCommentTapped: (cmt) {
onCommentTapped(cmt, then: () {
context
.read<NotificationCubit>()
.markAsRead(cmt);
});
},
onMarkAllAsReadTapped: () {
context
.read<NotificationCubit>()
.markAllAsRead();
},
onLoadMore: () {
context.read<NotificationCubit>().loadMore();
},
onRefresh: () {
HapticFeedback.lightImpact();
context.read<NotificationCubit>().refresh();
},
),
),
),
Positioned.fill(
top: 50,
child: Offstage(
offstage: pageType != PageType.settings,
offstage: pageType != _PageType.settings,
child: SingleChildScrollView(
child: Column(
children: [
@ -247,6 +243,7 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
),
const OfflineListTile(),
SwitchListTile(
title: const Text('Notification on New Reply'),
subtitle: const Text(
@ -307,14 +304,22 @@ class _ProfileScreenState extends State<ProfileScreen>
activeColor: Colors.orange,
),
SwitchListTile(
title: const Text('Show Comment Outlines'),
subtitle: const Text('be nice to your eyes.'),
value: preferenceState.showCommentBorder,
title: const Text('Mark Read Stories'),
subtitle: const Text(
'grey out stories you have read.'),
value: preferenceState.markReadStories,
onChanged: (val) {
HapticFeedback.lightImpact();
if (!val) {
context
.read<CacheCubit>()
.deleteAllReadStoryIds();
}
context
.read<PreferenceCubit>()
.toggleCommentBorderMode();
.toggleMarkReadStoriesMode();
},
activeColor: Colors.orange,
),
@ -327,13 +332,6 @@ class _ProfileScreenState extends State<ProfileScreen>
context
.read<PreferenceCubit>()
.toggleEyeCandyMode();
final inAppReview = InAppReview.instance;
inAppReview.isAvailable().then((available) {
if (available) {
inAppReview.requestReview();
}
});
},
activeColor: Colors.orange,
),
@ -346,13 +344,6 @@ class _ProfileScreenState extends State<ProfileScreen>
context
.read<PreferenceCubit>()
.toggleTrueDarkMode();
final inAppReview = InAppReview.instance;
inAppReview.isAvailable().then((available) {
if (available) {
inAppReview.requestReview();
}
});
},
activeColor: Colors.orange,
),
@ -377,7 +368,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v0.1.6',
applicationVersion: 'v0.2.1',
applicationIcon: Image.asset(
Constants.hackiIconPath,
height: 50,
@ -386,7 +377,8 @@ class _ProfileScreenState extends State<ProfileScreen>
children: [
ElevatedButton(
onPressed: () => LinkUtil.launchUrl(
'https://livinglist.github.io'),
Constants.portfolioLink,
),
child: Row(
children: const [
Icon(
@ -401,7 +393,8 @@ class _ProfileScreenState extends State<ProfileScreen>
),
ElevatedButton(
onPressed: () => LinkUtil.launchUrl(
'https://github.com/Livinglist/Hacki'),
Constants.githubLink,
),
child: Row(
children: const [
Icon(
@ -414,6 +407,24 @@ class _ProfileScreenState extends State<ProfileScreen>
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launchUrl(
Platform.isIOS
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const [
Icon(
Icons.thumb_up,
),
SizedBox(
width: 12,
),
Text('Like the App?'),
],
),
),
],
);
},
@ -444,18 +455,10 @@ class _ProfileScreenState extends State<ProfileScreen>
HackiApp.navigatorKey.currentState
?.pushNamed(SubmitScreen.routeName);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'You need to log in first.',
),
backgroundColor: Colors.orange,
action: SnackBarAction(
label: 'Log in',
textColor: Colors.black,
onPressed: onLoginTapped,
),
),
showSnackBar(
content: 'You need to log in first.',
label: 'Log in',
action: onLoginTapped,
);
}
},
@ -465,13 +468,13 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Inbox : '
//ignore: lines_longer_than_80_chars
// ignore: lines_longer_than_80_chars
'${notificationState.unreadCommentsIds.length}',
selected: pageType == PageType.notification,
selected: pageType == _PageType.notification,
onSelected: (val) {
if (val) {
setState(() {
pageType = PageType.notification;
pageType = _PageType.notification;
});
}
},
@ -481,11 +484,11 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Favorite',
selected: pageType == PageType.fav,
selected: pageType == _PageType.fav,
onSelected: (val) {
if (val) {
setState(() {
pageType = PageType.fav;
pageType = _PageType.fav;
});
}
},
@ -495,11 +498,11 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Submitted',
selected: pageType == PageType.history,
selected: pageType == _PageType.history,
onSelected: (val) {
if (val) {
setState(() {
pageType = PageType.history;
pageType = _PageType.history;
});
}
},
@ -509,11 +512,11 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Search',
selected: pageType == PageType.search,
selected: pageType == _PageType.search,
onSelected: (val) {
if (val) {
setState(() {
pageType = PageType.search;
pageType = _PageType.search;
});
}
},
@ -523,11 +526,11 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Settings',
selected: pageType == PageType.settings,
selected: pageType == _PageType.settings,
onSelected: (val) {
if (val) {
setState(() {
pageType = PageType.settings;
pageType = _PageType.settings;
});
}
},
@ -551,11 +554,8 @@ class _ProfileScreenState extends State<ProfileScreen>
void showThemeSettingDialog({bool useTrueDarkMode = false}) {
if (useTrueDarkMode) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Can't choose theme when using true dark mode."),
backgroundColor: Colors.orange,
),
showSnackBar(
content: "Can't choose theme when using true dark mode.",
);
return;
}
@ -591,6 +591,26 @@ class _ProfileScreenState extends State<ProfileScreen>
});
}
void onCommentTapped(Comment comment, {VoidCallback? then}) {
throttle.run(() {
locator
.get<StoriesRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then((tuple) {
if (tuple != null && mounted) {
goToStoryScreen(
args: StoryScreenArgs(
story: tuple.item1,
targetComments:
tuple.item2.isEmpty ? [comment] : [comment, ...tuple.item2],
onlyShowTargetComment: true,
),
)?.then((_) => then?.call());
}
});
});
}
void onLoginTapped() {
final usernameController = TextEditingController();
final passwordController = TextEditingController();
@ -603,12 +623,7 @@ class _ProfileScreenState extends State<ProfileScreen>
listener: (context, state) {
if (state.isLoggedIn) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logged in successfully!'),
backgroundColor: Colors.orange,
),
);
showSnackBar(content: 'Logged in successfully!');
}
},
builder: (context, state) {
@ -732,7 +747,10 @@ class _ProfileScreenState extends State<ProfileScreen>
child: ButtonBar(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () {
Navigator.pop(context);
context.read<AuthBloc>().add(AuthInitialize());
},
child: const Text(
'Cancel',
style: TextStyle(

View File

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class CenteredMessageView extends StatelessWidget {
const CenteredMessageView({
Key? key,
required this.content,
}) : super(key: key);
final String content;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
top: 120,
left: 40,
right: 40,
),
child: Text(
content,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
);
}
}

View File

@ -104,6 +104,13 @@ class InboxView extends StatelessWidget {
? textColor
: Colors.grey,
),
linkStyle: TextStyle(
color:
unreadCommentsIds.contains(e.id)
? Colors.orange
: Colors.orange
.withOpacity(0.6),
),
maxLines: 4,
onOpen: (link) =>
LinkUtil.launchUrl(link.url),

View File

@ -0,0 +1,64 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:wakelock/wakelock.dart';
class OfflineListTile extends StatelessWidget {
const OfflineListTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (previous, current) =>
previous.downloadStatus != current.downloadStatus,
listener: (context, state) {
if (state.downloadStatus == StoriesDownloadStatus.failure) {
Wakelock.disable();
}
},
buildWhen: (previous, current) =>
previous.downloadStatus != current.downloadStatus,
builder: (context, state) {
final downloading =
state.downloadStatus == StoriesDownloadStatus.downloading;
final downloaded =
state.downloadStatus == StoriesDownloadStatus.finished;
final trailingWidget = () {
if (downloading) {
return const SizedBox(
height: 24,
width: 24,
child: CustomCircularProgressIndicator(),
);
} else if (downloaded) {
return const Icon(Icons.check_circle);
}
return const Icon(Icons.download);
}();
return ListTile(
title: Text(
downloading ? 'Downloading All Stories...' : 'Download All Stories',
),
subtitle: const Text(
'download all latest stories that have at least one comment '
"for offline reading. (web page won't be downloaded)",
),
trailing: trailingWidget,
isThreeLine: true,
onTap: () {
Connectivity().checkConnectivity().then((res) {
if (res != ConnectivityResult.none) {
Wakelock.enable();
context.read<StoriesBloc>().add(StoriesDownload());
}
});
},
);
},
);
}
}

View File

@ -1,2 +1,4 @@
export 'centered_message_view.dart';
export 'custom_chip.dart';
export 'inbox_view.dart';
export 'offline_list_tile.dart';

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/utils/utils.dart';
@ -31,6 +31,7 @@ class _SearchScreenState extends State<SearchScreen> {
},
builder: (context, state) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -100,25 +101,24 @@ class _SearchScreenState extends State<SearchScreen> {
child: ListView(
children: [
...state.results
.map((e) => [
FadeIn(
child: StoryTile(
showWebPreview:
prefState.showComplexStoryTile,
story: e,
onTap: () {
HackiApp.navigatorKey.currentState!
.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(
story: e));
}),
),
if (!prefState.showComplexStoryTile)
const Divider(
height: 0,
.map(
(e) => [
FadeIn(
child: StoryTile(
showWebPreview:
prefState.showComplexStoryTile,
story: e,
onTap: () => goToStoryScreen(
args: StoryScreenArgs(story: e),
),
])
),
),
if (!prefState.showComplexStoryTile)
const Divider(
height: 0,
),
],
)
.expand((e) => e)
.toList(),
const SizedBox(

View File

@ -1,3 +1,6 @@
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -6,10 +9,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
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';
@ -27,14 +32,32 @@ enum _MenuAction {
cancel,
}
class StoryScreenArgs {
StoryScreenArgs({required this.story});
class StoryScreenArgs extends Equatable {
const StoryScreenArgs({
required this.story,
this.onlyShowTargetComment = false,
this.targetComments,
});
final Story story;
final bool onlyShowTargetComment;
final List<Comment>? targetComments;
@override
List<Object?> get props => [
story,
onlyShowTargetComment,
targetComments,
];
}
class StoryScreen extends StatefulWidget {
const StoryScreen({Key? key, required this.story}) : super(key: key);
const StoryScreen({
Key? key,
this.splitViewEnabled = false,
required this.story,
required this.parentComments,
}) : super(key: key);
static const String routeName = '/story';
@ -48,8 +71,12 @@ class StoryScreen extends StatefulWidget {
),
BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Story>(
offlineReading: context.read<StoriesBloc>().state.offlineReading,
item: args.story,
),
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetComment: args.targetComments?.last,
),
),
BlocProvider<EditCubit>(
create: (context) => EditCubit(),
@ -57,12 +84,43 @@ class StoryScreen extends StatefulWidget {
],
child: StoryScreen(
story: args.story,
parentComments: args.targetComments ?? [],
),
),
);
}
static Widget build(StoryScreenArgs args) {
return MultiBlocProvider(
key: ValueKey(args),
providers: [
BlocProvider<PostCubit>(
create: (context) => PostCubit(),
),
BlocProvider<CommentsCubit>(
create: (context) => CommentsCubit<Story>(
offlineReading: context.read<StoriesBloc>().state.offlineReading,
item: args.story,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetComment: args.targetComments?.last,
),
),
BlocProvider<EditCubit>(
create: (context) => EditCubit(),
),
],
child: StoryScreen(
story: args.story,
parentComments: args.targetComments ?? [],
splitViewEnabled: true,
),
);
}
final bool splitViewEnabled;
final Story story;
final List<Comment> parentComments;
@override
_StoryScreenState createState() => _StoryScreenState();
@ -105,6 +163,7 @@ class _StoryScreenState extends State<StoryScreen> {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
@ -136,333 +195,368 @@ class _StoryScreenState extends State<StoryScreen> {
final editCubit = context.read<EditCubit>();
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, authState) {
return BlocConsumer<PostCubit, PostState>(
return BlocListener<PostCubit, PostState>(
listener: (context, postState) {
if (postState.status == PostStatus.successful) {
editCubit.onReplySubmittedSuccessfully();
final verb =
editCubit.state.replyingTo == null ? 'updated' : 'submitted';
final msg = 'Comment $verb! ${(happyFaces..shuffle()).first}';
focusNode.unfocus();
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Comment submitted! ${(happyFaces..shuffle()).first}',
),
backgroundColor: Colors.orange,
));
showSnackBar(content: msg);
editCubit.onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Something went wrong...${(sadFaces..shuffle()).first}',
),
backgroundColor: Colors.orange,
action: SnackBarAction(
label: 'Okay',
onPressed: () =>
ScaffoldMessenger.of(context).hideCurrentSnackBar()),
));
showSnackBar(
content:
'Something went wrong...${(sadFaces..shuffle()).first}',
label: 'Okay',
action: ScaffoldMessenger.of(context).hideCurrentSnackBar,
);
context.read<PostCubit>().reset();
}
},
builder: (context, postState) {
return BlocConsumer<CommentsCubit, CommentsState>(
listener: (context, state) {
if (state.status == CommentsStatus.loaded) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
builder: (context, state) {
return BlocConsumer<EditCubit, EditState>(
listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo;
},
listener: (context, editState) {
if (editState.replyingTo != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length));
}
child: BlocConsumer<CommentsCubit, CommentsState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, state) {
if (state.status == CommentsStatus.loaded) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
builder: (context, state) {
final mainView = SmartRefresher(
scrollController: scrollController,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Colors.orange,
offset: topPadding,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (context, mode) {
Widget body;
if (mode == LoadStatus.idle) {
body = const Text('');
} else if (mode == LoadStatus.loading) {
body = const Text('');
} else if (mode == LoadStatus.failed) {
body = const Text(
'',
);
} else if (mode == LoadStatus.canLoading) {
body = const Text(
'',
);
} else {
commentEditingController.clear();
body = const Text('');
}
return SizedBox(
height: 55,
child: Center(child: body),
);
},
builder: (context, editState) {
final replyingTo = editCubit.state.replyingTo;
return Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
elevation: 0,
actions: [
ScrollUpIconButton(
scrollController: scrollController,
),
BlocBuilder<FavCubit, FavState>(
builder: (context, favState) {
final isFav =
favState.favIds.contains(widget.story.id);
return IconButton(
icon: DescribedFeatureOverlay(
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon(
isFav
? Icons.favorite
: Icons.favorite_border,
color: Colors.white,
),
featureId: Constants.featureAddStoryToFavList,
title: const Text('Fav a Story'),
description: const Text(
'Save this article for later.',
style: TextStyle(fontSize: 16),
),
child: Icon(
isFav
? Icons.favorite
: Icons.favorite_border,
color: isFav
? Colors.orange
: Theme.of(context).iconTheme.color,
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (isFav) {
context
.read<FavCubit>()
.removeFav(widget.story.id);
} else {
context
.read<FavCubit>()
.addFav(widget.story.id);
}
},
);
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
locator.get<CacheService>().resetComments();
context.read<CommentsCubit>().refresh();
},
onLoading: () {},
child: ListView(
primary: false,
children: [
SizedBox(
height: topPadding,
),
if (!widget.splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (widget.story !=
context.read<EditCubit>().state.replyingTo) {
commentEditingController.clear();
}
editCubit.onReplyTapped(widget.story);
focusNode.requestFocus();
},
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.message,
),
IconButton(
icon: DescribedFeatureOverlay(
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.stream,
color: Colors.white,
),
featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'),
description: const Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: 16),
),
child: const Icon(
Icons.stream,
),
),
onPressed: () => LinkUtil.launchUrl(
'https://news.ycombinator.com/item?id=${widget.story.id}'),
SlidableAction(
onPressed: (_) => onMorePressed(widget.story),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.more_horiz,
),
],
),
body: SmartRefresher(
scrollController: scrollController,
enablePullUp: true,
header: WaterDropMaterialHeader(
backgroundColor: Colors.orange,
offset: topPadding,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
),
child: Row(
children: [
Text(
widget.story.by,
style: const TextStyle(
color: Colors.orange,
),
),
const Spacer(),
Text(
widget.story.postedDate,
style: const TextStyle(
color: Colors.grey,
),
),
],
),
),
InkWell(
onTap: () => LinkUtil.launchUrl(
widget.story.url,
useReader: context
.read<PreferenceCubit>()
.state
.useReader,
),
child: Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
bottom: 12,
top: 12,
),
child: Text(
widget.story.title,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: widget.story.url.isNotEmpty
? Colors.orange
: null,
),
),
),
),
if (widget.story.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
child: SelectableHtml(
data: widget.story.text,
style: {
'body': Style(
fontSize: FontSize(
MediaQuery.of(context).textScaleFactor *
15,
),
),
'a': Style(
fontSize: FontSize(
MediaQuery.of(context).textScaleFactor *
15,
),
color: Colors.orange,
),
},
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
),
),
],
),
),
if (widget.story.text.isNotEmpty)
const SizedBox(
height: 8,
),
const Divider(
height: 0,
),
if (state.onlyShowTargetComment) ...[
TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(widget.story),
child: const Text('View all comments'),
),
const Divider(
height: 0,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.loaded) ...[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Colors.grey),
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (context, mode) {
Widget body;
if (mode == LoadStatus.idle) {
body = const Text('');
} else if (mode == LoadStatus.loading) {
body = const Text('');
} else if (mode == LoadStatus.failed) {
body = const Text(
'',
);
} else if (mode == LoadStatus.canLoading) {
body = const Text(
'',
);
} else {
body = const Text('');
),
],
...state.comments.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment: state.onlyShowTargetComment,
targetComments: widget.parentComments.sublist(
0, max(widget.parentComments.length - 1, 0)),
myUsername:
authState.isLoggedIn ? authState.username : null,
onReplyTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt !=
context.read<EditCubit>().state.replyingTo) {
commentEditingController.clear();
}
editCubit.onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
editCubit.onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMorePressed,
onStoryLinkTapped: (link) {
final regex = RegExp(r'\d+$');
final match = regex.stringMatch(link) ?? '';
final id = int.tryParse(match);
if (id != null) {
throttle.run(() {
locator
.get<StoriesRepository>()
.fetchParentStory(id: id)
.then((story) {
if (mounted) {
if (story != null) {
HackiApp.navigatorKey.currentState!
.pushNamed(
StoryScreen.routeName,
arguments:
StoryScreenArgs(story: story),
);
} else {}
}
});
});
} else {
LinkUtil.launchUrl(link);
}
return SizedBox(
height: 55,
child: Center(child: body),
);
},
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
locator.get<CacheService>().resetComments();
context.read<CommentsCubit>().refresh();
},
onLoading: () {},
child: ListView(
primary: false,
),
),
const SizedBox(
height: 240,
),
],
),
);
return BlocListener<EditCubit, EditState>(
listenWhen: (previous, current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited;
},
listener: (context, editState) {
if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length));
}
} else {
commentEditingController.clear();
}
},
child: widget.splitViewEnabled
? Material(
child: Stack(
children: [
SizedBox(
height: topPadding,
Positioned.fill(
child: mainView,
),
InkWell(
onTap: () {
setState(() {
if (widget.story != replyingTo) {
commentEditingController.clear();
}
editCubit.onItemTapped(widget.story);
focusNode.requestFocus();
});
},
onLongPress: () => onLongPressed(widget.story),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
),
child: Row(
children: [
Text(
widget.story.by,
style: const TextStyle(
color: Colors.orange,
),
),
const Spacer(),
Text(
widget.story.postedDate,
style: const TextStyle(
color: Colors.grey,
),
),
],
),
),
InkWell(
onTap: () => LinkUtil.launchUrl(
widget.story.url,
useReader: context
.read<PreferenceCubit>()
.state
.useReader,
),
child: Padding(
padding: const EdgeInsets.only(
left: 6,
right: 6,
bottom: 12,
top: 12,
),
child: Text(
widget.story.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
if (widget.story.text.isNotEmpty)
Html(
data: widget.story.text,
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
),
],
Positioned(
top: 0,
left: 0,
right: 0,
child: CustomAppBar(
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
story: widget.story,
scrollController: scrollController,
),
),
const Divider(
height: 0,
),
if (state.comments.isEmpty &&
state.status == CommentsStatus.loaded) ...[
const SizedBox(
height: 240,
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
editCubit.onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged: editCubit.onTextChanged,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Colors.white30),
),
),
],
...state.comments.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
myUsername: authState.isLoggedIn
? authState.username
: null,
onTap: (cmt) {
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt != replyingTo) {
commentEditingController.clear();
}
editCubit.onItemTapped(cmt);
focusNode.requestFocus();
},
onLongPress: onLongPressed,
onStoryLinkTapped: (link) {
final regex = RegExp(r'\d+$');
final match = regex.stringMatch(link) ?? '';
final id = int.tryParse(match);
if (id != null) {
throttle.run(() {
locator
.get<StoriesRepository>()
.fetchParentStory(id: id)
.then((story) {
if (mounted) {
if (story != null) {
HackiApp
.navigatorKey.currentState!
.pushNamed(
StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story),
);
} else {}
}
});
});
} else {
LinkUtil.launchUrl(link);
}
},
),
),
),
const SizedBox(
height: 240,
),
],
),
),
bottomSheet: Offstage(
offstage: !editCubit.state.showReplyBox,
child: ReplyBox(
)
: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
story: widget.story,
scrollController: scrollController,
),
body: mainView,
bottomSheet: ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
replyingTo: replyingTo,
isLoading: postState.status == PostStatus.loading,
onSendTapped: onSendTapped,
onCloseTapped: () {
editCubit.onReplyBoxClosed();
@ -472,18 +566,17 @@ class _StoryScreenState extends State<StoryScreen> {
onChanged: editCubit.onTextChanged,
),
),
);
},
);
},
);
},
);
},
),
);
},
);
}
void onLongPressed(Item item) {
void onMorePressed(Item item) {
HapticFeedback.lightImpact();
if (item.dead || item.deleted) {
return;
}
@ -517,7 +610,8 @@ class _StoryScreenState extends State<StoryScreen> {
} else if (voteState.status == VoteStatus.failureNotLoggedIn) {
showSnackBar(
content: 'Not logged in, no voting! (;O´)o',
withLoginAction: true,
action: onLoginTapped,
label: 'Log in',
);
} else if (voteState.status == VoteStatus.failureBeHumble) {
showSnackBar(content: 'No voting on your own post! (;O´)o');
@ -549,6 +643,9 @@ class _StoryScreenState extends State<StoryScreen> {
? const TextStyle(color: Colors.orange)
: null,
),
subtitle: item is Story
? Text(item.score.toString())
: null,
onTap: context.read<VoteCubit>().upvote,
),
ListTile(
@ -617,6 +714,7 @@ class _StoryScreenState extends State<StoryScreen> {
onBlockTapped(item, isBlocked);
break;
case _MenuAction.cancel:
break;
}
}
});
@ -680,10 +778,7 @@ class _StoryScreenState extends State<StoryScreen> {
}).then((yesTapped) {
if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Comment flagged!'),
backgroundColor: Colors.orange,
));
showSnackBar(content: 'Comment flagged!');
}
});
}
@ -752,10 +847,7 @@ class _StoryScreenState extends State<StoryScreen> {
} else {
context.read<BlocklistCubit>().addToBlocklist(item.by);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('User ${isBlocked ? 'unblocked' : 'blocked'}!'),
backgroundColor: Colors.orange,
));
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
}
});
}
@ -763,7 +855,9 @@ class _StoryScreenState extends State<StoryScreen> {
void onSendTapped() {
final authBloc = context.read<AuthBloc>();
final postCubit = context.read<PostCubit>();
final replyingTo = context.read<EditCubit>().state.replyingTo;
final editState = context.read<EditCubit>().state;
final replyingTo = editState.replyingTo;
final itemEdited = editState.itemBeingEdited;
if (authBloc.state.isLoggedIn) {
final text = commentEditingController.text;
@ -771,7 +865,9 @@ class _StoryScreenState extends State<StoryScreen> {
return;
}
if (replyingTo != null) {
if (itemEdited != null) {
postCubit.edit(text: text, id: itemEdited.id);
} else if (replyingTo != null) {
postCubit.post(text: text, to: replyingTo.id);
}
} else {
@ -792,12 +888,7 @@ class _StoryScreenState extends State<StoryScreen> {
listener: (context, state) {
if (state.isLoggedIn) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Logged in successfully! $happyFace'),
backgroundColor: Colors.orange,
),
);
showSnackBar(content: 'Logged in successfully! $happyFace');
}
},
builder: (context, state) {
@ -921,7 +1012,10 @@ class _StoryScreenState extends State<StoryScreen> {
child: ButtonBar(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () {
Navigator.pop(context);
context.read<AuthBloc>().add(AuthInitialize());
},
child: const Text(
'Cancel',
style: TextStyle(
@ -968,22 +1062,4 @@ class _StoryScreenState extends State<StoryScreen> {
},
);
}
void showSnackBar({required String content, bool withLoginAction = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
content,
),
backgroundColor: Colors.orange,
action: withLoginAction
? SnackBarAction(
label: 'Log in',
textColor: Colors.black,
onPressed: onLoginTapped,
)
: null,
),
);
}
}

View File

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/story/widgets/fav_icon_button.dart';
import 'package:hacki/screens/story/widgets/link_icon_button.dart';
import 'package:hacki/screens/story/widgets/pin_icon_button.dart';
import 'package:hacki/screens/story/widgets/scroll_up_icon_button.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
Key? key,
required ScrollController scrollController,
required Story story,
required Color backgroundColor,
}) : super(
key: key,
backgroundColor: backgroundColor,
elevation: 0,
actions: [
ScrollUpIconButton(
scrollController: scrollController,
),
PinIconButton(story: story),
FavIconButton(storyId: story.id),
LinkIconButton(storyId: story.id),
],
);
}

View File

@ -0,0 +1,53 @@
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
class FavIconButton extends StatelessWidget {
const FavIconButton({
Key? key,
required this.storyId,
}) : super(key: key);
final int storyId;
@override
Widget build(BuildContext context) {
return BlocBuilder<FavCubit, FavState>(
builder: (context, favState) {
final isFav = favState.favIds.contains(storyId);
return IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: Colors.white,
),
featureId: Constants.featureAddStoryToFavList,
title: const Text('Fav a Story'),
description: const Text(
'Add it to your favorites.',
style: TextStyle(fontSize: 16),
),
child: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Colors.orange : Theme.of(context).iconTheme.color,
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (isFav) {
context.read<FavCubit>().removeFav(storyId);
} else {
context.read<FavCubit>().addFav(storyId);
}
},
);
},
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/utils/utils.dart';
class LinkIconButton extends StatelessWidget {
const LinkIconButton({
Key? key,
required this.storyId,
}) : super(key: key);
final int storyId;
@override
Widget build(BuildContext context) {
return IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.stream,
color: Colors.white,
),
featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'),
description: const Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: 16),
),
child: const Icon(
Icons.stream,
),
),
onPressed: () =>
LinkUtil.launchUrl('https://news.ycombinator.com/item?id=$storyId'),
);
}
}

View File

@ -0,0 +1,66 @@
import 'dart:math';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
class PinIconButton extends StatelessWidget {
const PinIconButton({
Key? key,
required this.story,
}) : super(key: key);
final Story story;
@override
Widget build(BuildContext context) {
return BlocBuilder<PinCubit, PinState>(
builder: (context, pinState) {
final pinned = pinState.pinnedStoriesIds.contains(story.id);
return Transform.rotate(
angle: pi / 4,
child: Transform.translate(
offset: const Offset(2, 0),
child: IconButton(
icon: DescribedFeatureOverlay(
barrierDismissible: false,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Colors.white,
),
featureId: Constants.featurePinToTop,
title: const Text('Pin a Story'),
description: const Text(
'Pin this story to the top of your '
'home screen so that you can come'
' back later.',
style: TextStyle(fontSize: 16),
),
child: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: pinned
? Colors.orange
: Theme.of(context).iconTheme.color,
),
),
onPressed: () {
HapticFeedback.lightImpact();
if (pinned) {
context.read<PinCubit>().unpinStory(story);
} else {
context.read<PinCubit>().pinStory(story);
}
},
),
),
);
},
);
}
}

View File

@ -1,29 +1,29 @@
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/item.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/utils/link_util.dart';
class ReplyBox extends StatefulWidget {
const ReplyBox({
Key? key,
this.splitViewEnabled = false,
required this.focusNode,
required this.textEditingController,
required this.replyingTo,
required this.onSendTapped,
required this.onCloseTapped,
required this.onChanged,
required this.isLoading,
}) : super(key: key);
final bool splitViewEnabled;
final FocusNode focusNode;
final TextEditingController textEditingController;
final Item? replyingTo;
final VoidCallback onSendTapped;
final VoidCallback onCloseTapped;
final ValueChanged<String> onChanged;
final bool isLoading;
@override
_ReplyBoxState createState() => _ReplyBoxState();
@ -32,216 +32,266 @@ class ReplyBox extends StatefulWidget {
class _ReplyBoxState extends State<ReplyBox> {
bool expanded = false;
double? expandedHeight;
double? topPadding;
@override
Widget build(BuildContext context) {
expandedHeight ??= MediaQuery.of(context).size.height;
topPadding ??= MediaQuery.of(context).padding.top + kToolbarHeight;
return AnimatedContainer(
height: expanded ? expandedHeight : 100,
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: expanded ? Colors.transparent : Colors.black54,
offset: const Offset(0, 20), //(x,y)
blurRadius: 40,
),
],
),
child: Material(
child: Column(
children: [
AnimatedContainer(
height: expanded ? topPadding : 0,
duration: const Duration(milliseconds: 200),
),
Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
widget.replyingTo == null
? ''
: 'Replying '
'${widget.replyingTo?.by}',
style: const TextStyle(color: Colors.grey),
),
),
const Spacer(),
if (widget.replyingTo != null && !widget.isLoading) ...[
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
icon: const Icon(
FeatherIcons.code,
color: Colors.orange,
size: 18,
),
onPressed: expanded ? showTextPopup : null,
),
),
IconButton(
key: const Key('expand'),
icon: Icon(
expanded
? FeatherIcons.minimize2
: FeatherIcons.maximize2,
color: Colors.orange,
size: 18,
),
onPressed: () {
setState(() {
expanded = !expanded;
});
},
),
IconButton(
key: const Key('close'),
icon: const Icon(
Icons.close,
color: Colors.orange,
),
onPressed: () {
widget.onCloseTapped();
expanded = false;
},
),
],
if (widget.isLoading)
const Padding(
padding: EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.orange,
strokeWidth: 2,
),
),
)
else
IconButton(
key: const Key('send'),
icon: const Icon(
Icons.send,
color: Colors.orange,
),
onPressed: () {
widget.onSendTapped();
expanded = false;
},
),
],
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
focusNode: widget.focusNode,
controller: widget.textEditingController,
maxLines: 100,
decoration: const InputDecoration(
alignLabelWithHint: true,
contentPadding: EdgeInsets.zero,
hintText: '...',
hintStyle: TextStyle(
color: Colors.grey,
),
focusedBorder: InputBorder.none,
border: InputBorder.none,
),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
onChanged: widget.onChanged,
),
),
),
],
),
),
);
}
return BlocBuilder<EditCubit, EditState>(
buildWhen: (previous, current) =>
previous.showReplyBox != current.showReplyBox,
builder: (context, editState) {
return Offstage(
offstage: !editState.showReplyBox,
child: BlocBuilder<PostCubit, PostState>(
builder: (context, postState) {
return BlocBuilder<EditCubit, EditState>(
buildWhen: (previous, current) =>
previous.itemBeingEdited != current.itemBeingEdited ||
previous.replyingTo != current.replyingTo,
builder: (context, editState) {
final replyingTo = editState.replyingTo;
final isLoading = postState.status == PostStatus.loading;
void showTextPopup() {
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (_) {
return Container(
margin: const EdgeInsets.only(
left: 12,
right: 12,
top: 64,
bottom: 64,
),
color: Theme.of(context).canvasColor,
child: Padding(
padding: const EdgeInsets.only(
left: 12,
right: 6,
top: 6,
bottom: 12,
),
child: Column(
children: [
Material(
child: Row(
children: [
Text(
widget.replyingTo?.by ?? '',
style: const TextStyle(color: Colors.grey),
return Padding(
padding: EdgeInsets.only(
bottom: expanded
? 0
: widget.splitViewEnabled
? MediaQuery.of(context).viewInsets.bottom
: 0,
),
child: AnimatedContainer(
height: expanded ? expandedHeight : 100,
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
boxShadow: [
if (!context.read<SplitViewCubit>().state.enabled)
BoxShadow(
color: expanded
? Colors.transparent
: Colors.black26,
blurRadius: 40,
),
],
),
const Spacer(),
TextButton(
child: const Text('Copy All'),
onPressed: () => FlutterClipboard.copy(
widget.replyingTo?.text ?? '',
),
),
IconButton(
icon: const Icon(
Icons.close,
color: Colors.orange,
size: 18,
),
onPressed: () => Navigator.pop(context),
),
const SizedBox(
width: 6,
)
],
),
),
Expanded(
child: Scrollbar(
isAlwaysShown: true,
child: Padding(
padding: const EdgeInsets.only(right: 6),
child: SingleChildScrollView(
child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(),
text: widget.replyingTo?.text ?? '',
onOpen: (link) => LinkUtil.launchUrl(link.url),
child: Material(
child: Column(
children: [
if (context.read<SplitViewCubit>().state.enabled)
const Divider(
height: 0,
),
AnimatedContainer(
height: expanded ? 36 : 0,
duration: const Duration(milliseconds: 200),
),
Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
replyingTo == null
? 'Editing'
: 'Replying '
'${replyingTo.by}',
style: const TextStyle(color: Colors.grey),
),
),
const Spacer(),
if (!isLoading) ...[
...[
if (replyingTo != null)
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration:
const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
icon: const Icon(
FeatherIcons.code,
color: Colors.orange,
size: 18,
),
onPressed:
expanded ? showTextPopup : null,
),
),
IconButton(
key: const Key('expand'),
icon: Icon(
expanded
? FeatherIcons.minimize2
: FeatherIcons.maximize2,
color: Colors.orange,
size: 18,
),
onPressed: () {
setState(() {
expanded = !expanded;
});
},
),
],
IconButton(
key: const Key('close'),
icon: const Icon(
Icons.close,
color: Colors.orange,
),
onPressed: () {
widget.onCloseTapped();
expanded = false;
},
),
],
if (isLoading)
const Padding(
padding: EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.orange,
strokeWidth: 2,
),
),
)
else
IconButton(
key: const Key('send'),
icon: const Icon(
Icons.send,
color: Colors.orange,
),
onPressed: () {
widget.onSendTapped();
expanded = false;
},
),
],
),
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
focusNode: widget.focusNode,
controller: widget.textEditingController,
maxLines: 100,
decoration: const InputDecoration(
alignLabelWithHint: true,
contentPadding: EdgeInsets.zero,
hintText: '...',
hintStyle: TextStyle(
color: Colors.grey,
),
focusedBorder: InputBorder.none,
border: InputBorder.none,
),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
onChanged: widget.onChanged,
),
),
),
],
),
),
),
),
),
],
),
);
},
);
},
),
);
},
);
}
void showTextPopup() {
final replyingTo = context.read<EditCubit>().state.replyingTo;
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (_) {
return AlertDialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 24,
),
contentPadding: EdgeInsets.zero,
content: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 500,
maxHeight: 500,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 6,
),
child: Row(
children: [
Text(
replyingTo?.by ?? '',
style: const TextStyle(color: Colors.grey),
),
const Spacer(),
TextButton(
child: const Text('Copy All'),
onPressed: () => FlutterClipboard.copy(
replyingTo?.text ?? '',
).then((_) => HapticFeedback.selectionClick()),
),
IconButton(
icon: const Icon(
Icons.close,
color: Colors.orange,
size: 18,
),
onPressed: () => Navigator.pop(context),
),
],
),
),
Expanded(
child: Scrollbar(
isAlwaysShown: true,
child: Padding(
padding: const EdgeInsets.only(
left: 12,
right: 6,
top: 6,
),
child: SingleChildScrollView(
child: SelectableHtml(
scrollPhysics: const NeverScrollableScrollPhysics(),
data: replyingTo?.text ?? '',
style: {
'a': Style(
color: Colors.orange,
),
},
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link!),
),
),
),
),
),
],
),
));
},
);
}
}

View File

@ -1,2 +1,6 @@
export 'custom_app_bar.dart';
export 'fav_icon_button.dart';
export 'link_icon_button.dart';
export 'pin_icon_button.dart';
export 'reply_box.dart';
export 'scroll_up_icon_button.dart';

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
class SubmitScreen extends StatefulWidget {
const SubmitScreen({Key? key}) : super(key: key);
@ -41,6 +43,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
listener: (context, state) {
if (state.status == SubmitStatus.submitted) {
Navigator.pop(context);
HapticFeedback.lightImpact();
showSnackBar(
content: 'Post submitted successfully.',
);
@ -197,13 +200,4 @@ class _SubmitScreenState extends State<SubmitScreen> {
(textEditingController.text.isNotEmpty ||
urlEditingController.text.isNotEmpty);
}
void showSnackBar({required String content}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
content,
),
backgroundColor: Colors.orange,
));
}
}

View File

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class CircleTabIndicator extends Decoration {
CircleTabIndicator({required Color color, required double radius})
: _painter = _CirclePainter(color, radius);
final BoxPainter _painter;
@override
BoxPainter createBoxPainter([VoidCallback? onChanged]) => _painter;
}
class _CirclePainter extends BoxPainter {
_CirclePainter(Color color, this.radius)
: _paint = Paint()
..color = color
..isAntiAlias = true;
final Paint _paint;
final double radius;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration cfg) {
final circleOffset =
offset + Offset(cfg.size!.width / 2, cfg.size!.height - radius);
canvas.drawCircle(circleOffset, radius, _paint);
}
}

View File

@ -1,7 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
@ -11,25 +16,38 @@ class CommentTile extends StatelessWidget {
Key? key,
required this.myUsername,
required this.comment,
required this.onTap,
required this.onLongPress,
required this.onReplyTapped,
required this.onMoreTapped,
required this.onEditTapped,
required this.onStoryLinkTapped,
this.loadKids = true,
this.onlyShowTargetComment = false,
this.level = 0,
this.targetComments = const [],
}) : super(key: key);
final String? myUsername;
final Comment comment;
final int level;
final bool loadKids;
final Function(Comment) onTap;
final Function(Comment) onLongPress;
final bool onlyShowTargetComment;
final Function(Comment) onReplyTapped;
final Function(Comment) onMoreTapped;
final Function(Comment) onEditTapped;
final Function(String) onStoryLinkTapped;
final List<Comment> targetComments;
@override
Widget build(BuildContext context) {
return BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Comment>(item: comment),
lazy: false,
create: (_) => CommentsCubit<Comment>(
offlineReading: context.read<StoriesBloc>().state.offlineReading,
item: comment,
)..init(
onlyShowTargetComment: onlyShowTargetComment,
targetComment: targetComments.isNotEmpty ? targetComments.last : null,
),
child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (context, state) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -58,37 +76,64 @@ class CommentTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => onTap(comment),
onLongPress: () => onLongPress(comment),
onDoubleTap: () {
context.read<CommentsCubit>().collapse();
},
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) => onReplyTapped(comment),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.message,
),
if (context.read<AuthBloc>().state.user.id ==
comment.by)
SlidableAction(
onPressed: (_) => onEditTapped(comment),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.edit,
),
SlidableAction(
onPressed: (_) => onMoreTapped(comment),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: 6, right: 6, top: 6),
child: Row(
children: [
Text(
comment.by,
style: TextStyle(
//255, 152, 0
color: prefState.showEyeCandy
? orange
: color,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.lightImpact();
context.read<CommentsCubit>().collapse();
},
child: Padding(
padding: const EdgeInsets.only(
left: 6, right: 6, top: 6),
child: Row(
children: [
Text(
comment.by,
style: TextStyle(
//255, 152, 0
color: prefState.showEyeCandy
? orange
: color,
),
),
),
const Spacer(),
Text(
comment.postedDate,
style: const TextStyle(
color: Colors.grey,
const Spacer(),
Text(
comment.postedDate,
style: const TextStyle(
color: Colors.grey,
),
),
),
],
],
),
),
),
if (comment.deleted)
@ -141,9 +186,20 @@ class CommentTile extends StatelessWidget {
top: 6,
bottom: 12,
),
child: Linkify(
child: SelectableLinkify(
key: ObjectKey(comment),
text: comment.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
15,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
15,
color: Colors.orange,
),
onOpen: (link) {
if (link.url.contains(
'news.ycombinator.com/item')) {
@ -164,20 +220,28 @@ class CommentTile extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
children: state.comments
.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
myUsername: myUsername,
onTap: onTap,
onLongPress: onLongPress,
level: level + 1,
onStoryLinkTapped: onStoryLinkTapped,
),
children: [
...state.comments.map(
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
onlyShowTargetComment &&
targetComments.length > 1,
targetComments: targetComments.isNotEmpty
? targetComments.sublist(0,
max(targetComments.length - 1, 0))
: [],
myUsername: myUsername,
onReplyTapped: onReplyTapped,
onMoreTapped: onMoreTapped,
onEditTapped: onEditTapped,
level: level + 1,
onStoryLinkTapped: onStoryLinkTapped,
),
)
.toList(),
),
),
],
),
),
],
@ -195,9 +259,8 @@ class CommentTile extends StatelessWidget {
Theme.of(context).brightness == Brightness.dark
? 0.03
: 0.15;
final borderColor = prefState.showCommentBorder && level != 0
? color.withOpacity(0.5)
: Colors.transparent;
final borderColor =
level != 0 ? color.withOpacity(0.5) : Colors.transparent;
final commentColor = prefState.showEyeCandy
? color.withOpacity(commentBackgroundColorOpacity)
: Colors.transparent;

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart';
import 'package:hacki/screens/widgets/story_tile.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
@ -17,6 +19,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
required this.refreshController,
this.enablePullDown = true,
this.pinnable = false,
this.markReadStories = false,
this.useConsistentFontSize = false,
this.showOfflineBanner = false,
this.onRefresh,
this.onLoadMore,
this.onPinned,
@ -26,9 +31,15 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool showWebPreview;
final bool enablePullDown;
final bool markReadStories;
final bool showOfflineBanner;
/// Whether story tiles can be pinned to the top.
final bool pinnable;
/// Whether to use same font size for comment and story tiles.
final bool useConsistentFontSize;
final List<T> items;
final Widget? header;
final RefreshController? refreshController;
@ -41,8 +52,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
Widget build(BuildContext context) {
final child = ListView(
children: [
if (showOfflineBanner)
const OfflineBanner(
showExitButton: true,
),
if (header != null) header!,
...items.map((e) {
final wasRead =
context.read<CacheCubit>().state.storiesReadStatus[e.id] ?? false;
if (e is Story) {
return [
FadeIn(
@ -52,11 +69,14 @@ class ItemsListView<T extends Item> extends StatelessWidget {
motion: const BehindMotion(),
children: [
SlidableAction(
onPressed: (_) => onPinned?.call(e),
onPressed: (_) {
HapticFeedback.lightImpact();
onPinned?.call(e);
},
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: showWebPreview
? Icons.vertical_align_top
? Icons.push_pin_outlined
: null,
label: 'Pin to top',
),
@ -68,6 +88,8 @@ class ItemsListView<T extends Item> extends StatelessWidget {
story: e,
onTap: () => onTap(e),
showWebPreview: showWebPreview,
wasRead: markReadStories && wasRead,
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
),
),
),
@ -111,6 +133,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
child: Linkify(
text: e.text,
maxLines: 4,
linkStyle: const TextStyle(
color: Colors.orange,
),
onOpen: (link) =>
LinkUtil.launchUrl(link.url),
),

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
@ -184,7 +185,12 @@ class _LinkPreviewState extends State<LinkPreview> {
@override
Widget build(BuildContext context) {
final _height = (MediaQuery.of(context).size.height) * 0.15;
final screenWidth = MediaQuery.of(context).size.width;
final showSmallerPreviewPic =
Platform.isIOS && screenWidth > 428.0 && screenWidth < 850;
final _height = showSmallerPreviewPic
? 100.0
: (MediaQuery.of(context).size.height * 0.14).clamp(118.0, 140.0);
final loadingWidget = widget.placeholderWidget ??
Container(
height: _height,

View File

@ -83,22 +83,23 @@ class LinkView extends StatelessWidget {
child: Row(
children: <Widget>[
if (showMultiMedia)
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(
right: 5,
top: 5,
bottom: 5,
),
Padding(
padding: const EdgeInsets.only(
right: 5,
top: 5,
bottom: 5,
),
child: SizedBox(
height: layoutHeight,
width: layoutHeight,
child: (imageUri?.isEmpty ?? true) && imagePath != null
? Image.asset(
imagePath!,
fit: isIcon ? BoxFit.scaleDown : BoxFit.cover,
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
)
: CachedNetworkImage(
imageUrl: imageUri!,
fit: isIcon ? BoxFit.scaleDown : BoxFit.cover,
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
memCacheHeight: layoutHeight.toInt() * 4,
errorWidget: (context, _, dynamic __) {
return Image.asset(

View File

@ -12,16 +12,34 @@ import 'package:http/io_client.dart';
abstract class InfoBase {
late DateTime _timeout;
Map<String, dynamic> toJson();
}
/// Web Information
class WebInfo extends InfoBase {
WebInfo({this.title, this.icon, this.description, this.image});
WebInfo.fromJson(Map<String, dynamic> json)
: title = json['title'] as String?,
icon = json['icon'] as String?,
description = json['description'] as String?,
image = json['image'] as String?;
final String? title;
final String? icon;
final String? description;
final String? image;
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'title': title,
'icon': icon,
'description': description,
'image': image,
};
}
}
/// Image Information
@ -29,6 +47,13 @@ class WebImageInfo extends InfoBase {
WebImageInfo({this.image});
final String? image;
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'image': image,
};
}
}
/// Video Information
@ -137,9 +162,9 @@ class WebAnalyzer {
}
static Future<List<dynamic>?> _isolate(dynamic message) async {
//ignore: avoid_dynamic_calls
// ignore: avoid_dynamic_calls
final url = message[0] as String;
//ignore: avoid_dynamic_calls
// ignore: avoid_dynamic_calls
final multimedia = message[1] as bool;
final info = await _getInfo(url, multimedia);

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
class OfflineBanner extends StatelessWidget {
const OfflineBanner({
Key? key,
this.showExitButton = false,
}) : super(key: key);
final bool showExitButton;
@override
Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (previous, current) =>
previous.offlineReading != current.offlineReading,
builder: (context, state) {
if (state.offlineReading) {
return MaterialBanner(
content: Text(
'You are currently in offline mode. '
'${showExitButton ? 'Exit to fetch latest stories.' : ''}',
textAlign: showExitButton ? TextAlign.left : TextAlign.center,
),
backgroundColor: Colors.orangeAccent.withOpacity(0.3),
actions: [
if (showExitButton)
TextButton(
onPressed: () {
showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Exit offline mode?'),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: const Text('Cancel')),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
child: const Text(
'Yes',
style: TextStyle(
color: Colors.red,
),
),
),
],
);
}).then((value) {
if (value ?? false) {
context.read<StoriesBloc>().add(StoriesExitOffline());
context.read<AuthBloc>().add(AuthInitialize());
context.read<PinCubit>().init();
}
});
},
child: const Text('Exit'),
)
else
Container(),
],
);
}
return const SizedBox();
},
);
}
}

View File

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class StoriesListView extends StatefulWidget {
const StoriesListView({
Key? key,
required this.storyType,
required this.header,
required this.onStoryTapped,
}) : super(key: key);
final StoryType storyType;
final Widget header;
final ValueChanged<Story> onStoryTapped;
@override
State<StoriesListView> createState() => _StoriesListViewState();
}
class _StoriesListViewState extends State<StoriesListView> {
final refreshController = RefreshController();
@override
void dispose() {
super.dispose();
refreshController.dispose();
}
@override
Widget build(BuildContext context) {
final storyType = widget.storyType;
final header = widget.header;
final onStoryTapped = widget.onStoryTapped;
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (previous, current) =>
previous.showComplexStoryTile != current.showComplexStoryTile,
builder: (context, preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (previous, current) =>
previous.statusByType[storyType] !=
current.statusByType[storyType],
listener: (context, state) {
if (state.statusByType[storyType] == StoriesStatus.loaded) {
refreshController
..refreshCompleted(resetFooterState: true)
..loadComplete();
}
},
buildWhen: (previous, current) =>
(current.currentPageByType[storyType] == 0 &&
previous.currentPageByType[storyType] == 0) ||
(previous.storiesByType[storyType]!.length !=
current.storiesByType[storyType]!.length),
builder: (context, state) {
return ItemsListView<Story>(
pinnable: true,
showOfflineBanner: true,
markReadStories:
context.read<PreferenceCubit>().state.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
refreshController: refreshController,
items: state.storiesByType[storyType]!,
onRefresh: () {
HapticFeedback.lightImpact();
context
.read<StoriesBloc>()
.add(StoriesRefresh(type: storyType));
},
onLoadMore: () {
context
.read<StoriesBloc>()
.add(StoriesLoadMore(type: storyType));
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header,
);
},
);
},
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/config/constants.dart';
@ -10,19 +12,28 @@ import 'package:shimmer/shimmer.dart';
class StoryTile extends StatelessWidget {
const StoryTile({
Key? key,
this.wasRead = false,
required this.showWebPreview,
required this.story,
required this.onTap,
this.simpleTileFontSize = 16,
}) : super(key: key);
final bool showWebPreview;
final bool wasRead;
final Story story;
final VoidCallback onTap;
final double simpleTileFontSize;
@override
Widget build(BuildContext context) {
if (showWebPreview) {
final height = (MediaQuery.of(context).size.height) * 0.15;
final screenWidth = MediaQuery.of(context).size.width;
final showSmallerPreviewPic =
Platform.isIOS && screenWidth > 428.0 && screenWidth < 850;
final height = showSmallerPreviewPic
? 100.0
: (MediaQuery.of(context).size.height * 0.14).clamp(118.0, 140.0);
if (story.url.isNotEmpty) {
return TapDownWrapper(
@ -43,19 +54,16 @@ class StoryTile extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(
right: 5,
bottom: 5,
top: 5,
),
child: Container(
height: height,
width: height,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.only(
right: 5,
bottom: 5,
top: 5,
),
child: Container(
height: height,
width: height,
color: Colors.white,
),
),
Expanded(
@ -115,10 +123,12 @@ class StoryTile extends StatelessWidget {
backgroundColor: Colors.transparent,
borderRadius: 0,
removeElevation: true,
bodyMaxLines: 4,
bodyMaxLines: height == 100 ? 3 : 4,
errorTitle: story.title,
titleStyle: TextStyle(
color: Theme.of(context).textTheme.subtitle1!.color,
color: wasRead
? Colors.grey[500]
: Theme.of(context).textTheme.subtitle1!.color,
fontWeight: FontWeight.bold,
),
),
@ -143,9 +153,11 @@ class StoryTile extends StatelessWidget {
onTap: (_) {},
url: '',
imagePath: Constants.hackerNewsLogoPath,
bodyMaxLines: 4,
bodyMaxLines: height == 100 ? 3 : 4,
titleTextStyle: TextStyle(
color: Theme.of(context).textTheme.subtitle1!.color,
color: wasRead
? Colors.grey[500]
: Theme.of(context).textTheme.subtitle1!.color,
fontWeight: FontWeight.bold,
),
),
@ -170,7 +182,10 @@ class StoryTile extends StatelessWidget {
Expanded(
child: Text(
story.title,
style: const TextStyle(fontSize: 15),
style: TextStyle(
color: wasRead ? Colors.grey[500] : null,
fontSize: simpleTileFontSize,
),
),
),
],

View File

@ -1,8 +1,11 @@
export 'circle_tab_indicator.dart';
export 'comment_tile.dart';
export 'custom_circular_progress_indicator.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';
export 'link_preview/link_preview.dart';
export 'offline_banner.dart';
export 'spring_curve.dart';
export 'stories_list_view.dart';
export 'story_tile.dart';
export 'tap_down_wrapper.dart';

View File

@ -0,0 +1,119 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart';
/// FirebaseClient wraps a REST client for a Firebase realtime database.
///
/// The client supports authentication and GET, PUT, POST, DELETE
/// and PATCH methods.
class FirebaseClient {
/// Creates a new FirebaseClient with [credential] and optional [client].
///
/// For credential you can either use Firebase app's secret or
/// an authentication token.
/// See: <https://firebase.google.com/docs/reference/rest/database/user-auth>.
FirebaseClient(this.credential, {Client? client})
: _client = client ?? Client();
/// Creates a new anonymous FirebaseClient with optional [client].
FirebaseClient.anonymous({Client? client})
: credential = null,
_client = client ?? Client();
/// Auth credential.
final String? credential;
final Client _client;
/// Reads data from database using a HTTP GET request.
/// The response from a successful request contains a data being retrieved.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-get>.
Future<dynamic> get(dynamic uri) => send('GET', uri);
/// Writes or replaces data in database using a HTTP PUT request.
/// The response from a successful request contains a data being written.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-put>.
Future<dynamic> put(dynamic uri, dynamic json) =>
send('PUT', uri, json: json);
/// Pushes data to database using a HTTP POST request.
/// The response from a successful request contains a key of the new data
/// being added.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-post>.
Future<dynamic> post(dynamic uri, dynamic json) =>
send('POST', uri, json: json);
/// Updates specific children at a location without overwriting existing data
/// using a HTTP PATCH request.
/// The response from a successful request contains a data being written.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-patch>.
Future<dynamic> patch(dynamic uri, dynamic json) =>
send('PATCH', uri, json: json);
/// Deletes data from database using a HTTP DELETE request.
/// The response from a successful request contains a JSON with `null`.
///
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-delete>.
Future<void> delete(dynamic uri) => send('DELETE', uri);
/// Creates a request with a HTTP [method], [url] and optional data.
/// The [url] can be either a `String` or `Uri`.
Future<Object?> send(String method, dynamic url, {dynamic json}) async {
final uri = url is String ? Uri.parse(url) : url as Uri;
final request = Request(method, uri);
if (credential != null) {
request.headers['Authorization'] = 'Bearer $credential';
}
if (json != null) {
request.headers['Content-Type'] = 'application/json';
request.body = jsonEncode(json);
}
final streamedResponse = await _client.send(request);
final response = await Response.fromStream(streamedResponse);
Object? bodyJson;
try {
bodyJson = jsonDecode(response.body);
} on FormatException {
final contentType = response.headers['content-type'];
if (contentType != null && !contentType.contains('application/json')) {
throw Exception(
"Returned value was not JSON. Did the uri end with '.json'?");
}
rethrow;
}
if (response.statusCode != 200) {
if (bodyJson is Map) {
final dynamic error = bodyJson['error'];
if (error != null) {
throw FirebaseClientException(response.statusCode, error.toString());
}
}
throw FirebaseClientException(response.statusCode, bodyJson.toString());
}
return bodyJson;
}
/// Closes the client and cleans up any associated resources.
void close() => _client.close();
}
class FirebaseClientException implements Exception {
FirebaseClientException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => '$message ($statusCode)';
}

View File

@ -1 +1,2 @@
export 'cache_service.dart';
export 'firebase_client.dart';

View File

@ -6,10 +6,21 @@ class LinkUtil {
static final _browser = ChromeSafariBrowser();
static void launchUrl(String link, {bool useReader = false}) {
String rinseLink(String link) {
if (link.contains(')')) {
final regex = RegExp(r'\).*$');
final match = regex.stringMatch(link) ?? '';
return link.replaceAll(match, '');
}
return link;
}
canLaunch(link).then((val) {
if (val) {
final rinsedLink = rinseLink(link);
_browser.open(
url: Uri.parse(link),
url: Uri.parse(rinsedLink),
options: ChromeSafariBrowserClassOptions(
ios: IOSSafariOptions(
entersReaderIfAvailable: useReader,

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 0.1.6+21
version: 0.2.1+32
publish_to: none
environment:
@ -8,22 +8,19 @@ environment:
dependencies:
adaptive_theme: ^2.3.0
algolia: ^1.0.1
badges: ^2.0.2
bloc: ^7.0.0
bloc: ^8.0.3
cached_network_image: ^3.2.0
clipboard: ^0.1.3
collection:
connectivity_plus: ^2.2.1
dio: ^4.0.4
equatable: 2.0.3
fast_gbk: ^1.0.0
feature_discovery: ^0.14.0
firebase_analytics: ^8.3.4
firebase_core: ^1.6.0
flutter:
sdk: flutter
flutter_app_badger: ^1.3.0
flutter_bloc: ^7.1.0
flutter_bloc: ^8.0.1
flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1
flutter_html: ^2.2.1
@ -34,25 +31,28 @@ dependencies:
font_awesome_flutter: ^9.2.0
gbk_codec: ^0.4.0
get_it: 7.2.0
hive: ^2.0.6
html: ^0.15.0
html_unescape: ^2.0.0
http: ^0.13.3
in_app_review: ^2.0.3
intl: ^0.17.0
path: ^1.8.0
path_provider: ^2.0.8
pull_to_refresh: ^2.0.0
responsive_builder: ^0.4.2
sembast: ^3.1.1+1
shared_preferences: ^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
dev_dependencies:
bloc_test: ^8.1.0
bloc_test: ^9.0.3
flutter_test:
sdk: flutter
mocktail: ^0.1.0
mocktail: ^0.3.0
very_good_analysis: ^2.3.0
flutter: