Compare commits

...

9 Commits

Author SHA1 Message Date
1e5af07691 improve UX. (#110) 2023-01-25 21:47:05 -08:00
ecf8c902dc bump flutter and linter version. (#108) 2023-01-25 12:33:06 -08:00
d3ede8546b update link_view.dart (#107) 2023-01-19 01:15:45 -08:00
53562ad260 fix loading indicator. (#106) 2023-01-19 00:23:28 -08:00
6c8e7a7cb9 update loading indicator. (#105) 2023-01-18 16:10:25 -08:00
56c0245335 fix StoryTile. (#104) 2023-01-18 14:31:49 -08:00
0cbd38a530 fix StoryTile. (#103) 2023-01-17 23:47:04 -08:00
7c6da2c36a fix story tile overflow. (#102) 2023-01-17 23:02:57 -08:00
185140feb4 show url in story tile. (#101) 2023-01-17 21:49:10 -08:00
52 changed files with 1515 additions and 1147 deletions

View File

@ -12,12 +12,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLUTTER_VERSION: "3.3.10"
FLUTTER_VERSION: "3.7.0"
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.3.10'
flutter-version: '3.7.0'
channel: 'stable'
- run: flutter pub get
- run: flutter format --set-exit-if-changed .

View File

@ -31,7 +31,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
cache: true
flutter-version: 3.3.10
flutter-version: 3.7.0
- run: flutter pub get
- run: flutter format --set-exit-if-changed .
- run: flutter analyze

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
include: package:very_good_analysis/analysis_options.3.1.0.yaml
linter:
rules:
parameter_assignments: false

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
include: package:very_good_analysis/analysis_options.3.1.0.yaml
linter:
rules:
parameter_assignments: false

View File

@ -1,5 +1,82 @@
import Flutter
import UIKit
import Foundation
typealias APNSHandler = ()->Void
let keyKey = "key"
let valKey = "val"
final class SharedPrefsCore {
fileprivate static let shared: SharedPrefsCore = SharedPrefsCore()
fileprivate func setBool(key: String?, val: Bool?) -> Bool {
guard let key = key,
let val = val else {
return false
}
let keyStore = NSUbiquitousKeyValueStore()
let allVals = keyStore.dictionaryRepresentation;
let allKeys = allVals.keys
// Limit is 1024, reserve rest slots for fav and pins.
if allKeys.count >= 1000 {
for key in allKeys.filter({ $0.contains("hasRead") }) {
keyStore.removeObject(forKey: key)
}
}
keyStore.set(val, forKey: key)
return true
}
fileprivate func getBool(key: String?) -> Bool {
guard let key = key else {
return false
}
let keyStore = NSUbiquitousKeyValueStore()
let val = keyStore.bool(forKey: key)
return val
}
fileprivate func setStringList(key: String?, val: [String]?) -> Bool {
guard let key = key,
let val = val else {
return false
}
let keyStore = NSUbiquitousKeyValueStore()
keyStore.set(val, forKey: key)
return true
}
fileprivate func getStringList(key: String?) -> [Any] {
guard let key = key else {
return [Any]()
}
let keyStore = NSUbiquitousKeyValueStore()
let list = keyStore.array(forKey: key) as [Any]? ?? [Any]()
return list
}
fileprivate func clearAll() -> Bool{
let keyStore = NSUbiquitousKeyValueStore()
let allVals = keyStore.dictionaryRepresentation;
let allKeys = allVals.keys
for key in allKeys.filter({ $0.contains("hasRead") }) {
keyStore.removeObject(forKey: key)
}
return true
}
}
public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
@ -7,46 +84,49 @@ public class SwiftSyncedSharedPreferencesPlugin: NSObject, FlutterPlugin {
let instance = SwiftSyncedSharedPreferencesPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "setBool":
if let params = call.arguments as? [String: Any] {
let info: [String: Any] = ["result": result,
"params": params]
NotificationCenter.default.post(name: Notification.Name("setBool"), object: nil, userInfo: info)
let val = params[valKey] as? Bool
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.setBool(key: key, val: val)
result(res)
}
return
case "getBool":
if let params = call.arguments as? [String: Any] {
let info: [String: Any] = ["result": result,
"params": params]
NotificationCenter.default.post(name: Notification.Name("getBool"), object: nil, userInfo: info)
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.getBool(key: key)
result(res)
}
return
case "setStringList":
if let params = call.arguments as? [String: Any] {
let info: [String: Any] = ["result": result,
"params": params]
NotificationCenter.default.post(name: Notification.Name("setStringList"), object: nil, userInfo: info)
let val = params[valKey] as? [String]
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.setStringList(key: key, val: val)
result(res)
}
return
case "getStringList":
if let params = call.arguments as? [String: Any] {
let info: [String: Any] = ["result": result,
"params": params]
NotificationCenter.default.post(name: Notification.Name("getStringList"), object: nil, userInfo: info)
let key = params[keyKey] as? String
let res = SharedPrefsCore.shared.getStringList(key: key)
result(res)
}
return
case "clearAll":
if let params = call.arguments as? [String: Any] {
let info: [String: Any] = ["result": result,
"params": params]
NotificationCenter.default.post(name: Notification.Name("clearAll"), object: nil, userInfo: info)
let res = SharedPrefsCore.shared.clearAll()
result(res)
}
return

View File

@ -12,7 +12,7 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage (3.3.1):
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_siri_suggestions (0.0.1):
- Flutter
@ -24,6 +24,9 @@ PODS:
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- ReachabilitySwift (5.0.0)
@ -31,6 +34,9 @@ PODS:
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_ios (0.0.1):
- Flutter
- sqflite (0.0.2):
@ -56,9 +62,11 @@ DEPENDENCIES:
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
@ -90,12 +98,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
sqflite:
@ -116,24 +128,26 @@ SPEC CHECKSUMS:
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937
COCOAPODS: 1.11.3

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 51;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -21,7 +21,6 @@
E530B1AD283B54DA004E8EB6 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E530B1AC283B54DA004E8EB6 /* ActionViewController.swift */; };
E530B1B0283B54DA004E8EB6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E530B1AE283B54DA004E8EB6 /* MainInterface.storyboard */; };
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E54B4753282B3B8900579261 /* HackiCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E54B4752282B3B8900579261 /* HackiCore.swift */; };
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; };
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
@ -97,7 +96,6 @@
E530B1AF283B54DA004E8EB6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
E530B1B1283B54DA004E8EB6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E530B1B9283B54E4004E8EB6 /* Action Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Action Extension.entitlements"; sourceTree = "<group>"; };
E54B4752282B3B8900579261 /* HackiCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackiCore.swift; sourceTree = "<group>"; };
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; };
E59F28EE283B477D00512089 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = "<group>"; };
@ -177,7 +175,6 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
E54B4752282B3B8900579261 /* HackiCore.swift */,
);
path = Runner;
sourceTree = "<group>";
@ -363,6 +360,7 @@
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@ -416,6 +414,7 @@
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@ -437,7 +436,6 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
E54B4753282B3B8900579261 /* HackiCore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -16,8 +16,6 @@ import flutter_local_notifications
let center = UNUserNotificationCenter.current()
center.delegate = self
HackiCore.start()
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)

View File

@ -1,134 +0,0 @@
//
// HackiCore.swift
// Runner
//
// Created by Jiaqi Feng on 5/10/22.
//
import Foundation
import Flutter
extension Notification.Name {
static let setBool = Notification.Name("setBool")
static let getBool = Notification.Name("getBool")
static let setStringList = Notification.Name("setStringList")
static let getStringList = Notification.Name("getStringList")
static let clearAll = Notification.Name("clearAll")
}
typealias APNSHandler = ()->Void
final class HackiCore: NSObject {
private static let keyKey = "key"
private static let valKey = "val"
private static let shared: HackiCore = HackiCore()
private let notificationCenter = NotificationCenter.default
// Called at app launch
class func start() {
shared.registerNotifications()
}
private class func setupFlutterEvent(channelName: String, handler: NSObjectProtocol & FlutterStreamHandler) {
guard let rootVC = UIApplication.shared.delegate?.window.unsafelyUnwrapped?.rootViewController as? FlutterViewController else { return }
let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: rootVC.binaryMessenger)
eventChannel.setStreamHandler(handler)
}
private func registerNotifications() {
// SyncedSharedPreferences
notificationCenter.addObserver(self, selector: #selector(setBool(_:)), name: .setBool, object: nil)
notificationCenter.addObserver(self, selector: #selector(getBool(_:)), name: .getBool, object: nil)
notificationCenter.addObserver(self, selector: #selector(setStringList(_:)), name: .setStringList, object: nil)
notificationCenter.addObserver(self, selector: #selector(getStringList(_:)), name: .getStringList, object: nil)
notificationCenter.addObserver(self, selector: #selector(clearAll(_:)), name: .clearAll, object: nil)
}
@objc private func setBool(_ notification: Notification) {
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
guard
let params = notification.userInfo?["params"] as? [String: Any],
let key = params[HackiCore.keyKey] as? String,
let val = params[HackiCore.valKey] as? Bool else {
resultCompletionBlock(false)
return
}
let keyStore = NSUbiquitousKeyValueStore()
let allVals = keyStore.dictionaryRepresentation;
let allKeys = allVals.keys
// Limit is 1024, reserve rest slots for fav and pins.
if allKeys.count >= 1000 {
for key in allKeys.filter({ $0.contains("hasRead") }) {
keyStore.removeObject(forKey: key)
}
}
keyStore.set(val, forKey: key)
resultCompletionBlock(true)
}
@objc private func getBool(_ notification: Notification) {
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
guard
let params = notification.userInfo?["params"] as? [String: Any],
let key = params[HackiCore.keyKey] as? String else {
resultCompletionBlock(false)
return
}
let keyStore = NSUbiquitousKeyValueStore()
let val = keyStore.bool(forKey: key)
resultCompletionBlock(val)
}
@objc private func setStringList(_ notification: Notification) {
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
guard
let params = notification.userInfo?["params"] as? [String: Any],
let key = params[HackiCore.keyKey] as? String,
let val = params[HackiCore.valKey] as? [String] else {
resultCompletionBlock(false)
return
}
let keyStore = NSUbiquitousKeyValueStore()
keyStore.set(val, forKey: key)
resultCompletionBlock(true)
}
@objc private func getStringList(_ notification: Notification) {
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
guard
let params = notification.userInfo?["params"] as? [String: Any],
let key = params[HackiCore.keyKey] as? String else {
resultCompletionBlock(false)
return
}
let keyStore = NSUbiquitousKeyValueStore()
let list = keyStore.array(forKey: key) as [Any]? ?? [Any]()
resultCompletionBlock(list)
}
@objc private func clearAll(_ notification: Notification) {
guard let resultCompletionBlock: FlutterResult = notification.userInfo?["result"] as? FlutterResult else { fatalError(" failed to obtain result block") }
let keyStore = NSUbiquitousKeyValueStore()
let allVals = keyStore.dictionaryRepresentation;
let allKeys = allVals.keys
for key in allKeys.filter({ $0.contains("hasRead") }) {
keyStore.removeObject(forKey: key)
}
resultCompletionBlock(true)
}
}

View File

@ -74,5 +74,7 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -84,6 +84,9 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
const StoriesState.init().copyWith(
offlineReading: hasCachedStories,
currentPageSize: pageSize,
downloadStatus: state.downloadStatus,
storiesDownloaded: state.storiesDownloaded,
storiesToBeDownloaded: state.storiesToBeDownloaded,
),
);
for (final StoryType type in types) {
@ -374,7 +377,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesPageSizeChanged event,
Emitter<StoriesState> emit,
) async {
emit(const StoriesState.init());
add(StoriesInitialize());
}

View File

@ -10,6 +10,8 @@ abstract class Constants {
static const String googlePlayLink =
'https://play.google.com/store/apps/details?id=com.jiaqifeng.hacki&hl=en_US&gl=US';
static const String sponsorLink = 'https://github.com/sponsors/Livinglist';
static const String guidelineLink =
'https://news.ycombinator.com/newsguidelines.html';
static const String _imagePath = 'assets/images';
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';

View File

@ -80,7 +80,7 @@ class CommentsCubit extends Cubit<CommentsState> {
state.copyWith(
comments: targetParents,
onlyShowTargetComment: true,
status: CommentsStatus.loaded,
status: CommentsStatus.allLoaded,
),
);
@ -141,21 +141,21 @@ class CommentsCubit extends Cubit<CommentsState> {
}
Future<void> refresh() async {
if (state.offlineReading) {
emit(
state.copyWith(
status: CommentsStatus.loaded,
),
);
return;
}
emit(
state.copyWith(
status: CommentsStatus.loading,
),
);
if (state.offlineReading) {
emit(
state.copyWith(
status: CommentsStatus.allLoaded,
),
);
return;
}
_collapseCache.resetCollapsedComments();
await _streamSubscription?.cancel();
@ -195,7 +195,6 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(
state.copyWith(
item: updatedItem,
status: CommentsStatus.loaded,
),
);
}
@ -370,12 +369,17 @@ class CommentsCubit extends Cubit<CommentsState> {
if (!isHidden) {
_streamSubscription?.pause();
emit(
state.copyWith(
status: CommentsStatus.loaded,
),
);
}
emit(
state.copyWith(
currentPage: state.currentPage + 1,
status: CommentsStatus.loaded,
),
);
}

View File

@ -64,6 +64,8 @@ class PreferenceState extends Equatable {
bool get showMetadata => _isOn<MetadataModePreference>();
bool get showUrl => _isOn<StoryUrlModePreference>();
bool get tapAnywhereToCollapse => _isOn<CollapseModePreference>();
FetchMode get fetchMode => FetchMode.values

View File

@ -22,12 +22,12 @@ extension ContextExtension on BuildContext {
static double _screenWidth = 0;
static double _storyTileHeight = 0;
static int _storyTileMaxLines = 4;
static const double _screenWidthLowerBound = 430,
_screenWidthUpperBound = 850,
_picHeightLowerBound = 110,
_picHeightUpperBound = 128,
_smallPicHeight = 100,
_picHeightFactor = 0.3;
static const double _screenWidthLowerBound = 430;
static const double _screenWidthUpperBound = 850;
static const double _picHeightLowerBound = 110;
static const double _picHeightUpperBound = 128;
static const double _smallPicHeight = 100;
static const double _picHeightFactor = 0.3;
double get storyTileHeight {
final double screenWidth =

View File

@ -19,7 +19,7 @@ extension StateExtension on State {
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyText1?.color,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,

View File

@ -90,9 +90,9 @@ Future<void> main({bool testing = false}) async {
} else if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
statusBarColor: Palette.transparent,
systemNavigationBarColor: Palette.transparent,
systemNavigationBarDividerColor: Palette.transparent,
),
);

View File

@ -18,10 +18,11 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
CommentsOrderPreference(),
FontSizePreference(),
// order here reflects the order on settings screen.
const NotificationModePreference(),
const CollapseModePreference(),
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const NotificationModePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
@ -50,6 +51,7 @@ const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = false;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
@ -132,6 +134,25 @@ class MetadataModePreference extends BooleanPreference {
'''show number of comments and post date in story tile.''';
}
class StoryUrlModePreference extends BooleanPreference {
const StoryUrlModePreference({bool? val})
: super(val: val ?? _storyUrlModeDefaultValue);
@override
StoryUrlModePreference copyWith({required bool? val}) {
return StoryUrlModePreference(val: val);
}
@override
String get key => 'storyUrlMode';
@override
String get title => 'Show Url';
@override
String get subtitle => '''show url in story tile.''';
}
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {

View File

@ -1,3 +1,4 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart';
enum StoryType {
@ -67,6 +68,24 @@ class Story extends Item {
type: '',
);
Story.placeholder()
: super(
id: 0,
score: 0,
descendants: 0,
time: 1171872000,
by: 'Y Combinator',
title: 'Hacker News Guidelines',
url: Constants.guidelineLink,
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.fromJson(Map<String, dynamic> json)
: super(
descendants: json['descendants'] as int? ?? 0,
@ -91,6 +110,12 @@ class Story extends Item {
String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
String get readableUrl {
final Uri url = Uri.parse(this.url);
final String authority = url.authority.replaceFirst('www.', '');
return authority;
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/postable_repository.dart';
@ -9,13 +8,12 @@ import 'package:logger/logger.dart';
class AuthRepository extends PostableRepository {
AuthRepository({
Dio? dio,
super.dio,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(dio: dio);
_logger = logger ?? locator.get<Logger>();
final PreferenceRepository _preferenceRepository;
final Logger _logger;

View File

@ -8,10 +8,9 @@ import 'package:hacki/repositories/preference_repository.dart';
import 'package:hacki/utils/utils.dart';
class PostRepository extends PostableRepository {
PostRepository({Dio? dio, PreferenceRepository? storageRepository})
PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository =
storageRepository ?? locator.get<PreferenceRepository>(),
super(dio: dio);
storageRepository ?? locator.get<PreferenceRepository>();
final PreferenceRepository _preferenceRepository;

View File

@ -4,7 +4,7 @@ import 'dart:io';
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -182,7 +182,7 @@ class _HomeScreenState extends State<HomeScreen>
),
],
),
child: Container(
child: ColoredBox(
color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
@ -190,6 +190,7 @@ class _HomeScreenState extends State<HomeScreen>
onTap: () => onStoryTapped(story, isPin: true),
showWebPreview: preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
),
),
),
@ -507,9 +508,8 @@ class _HomeScreenState extends State<HomeScreen>
class _MobileHomeScreen extends StatelessWidget {
const _MobileHomeScreen({
Key? key,
required this.homeScreen,
}) : super(key: key);
});
final Widget homeScreen;
@ -533,9 +533,8 @@ class _MobileHomeScreen extends StatelessWidget {
class _TabletHomeScreen extends StatelessWidget {
const _TabletHomeScreen({
Key? key,
required this.homeScreen,
}) : super(key: key);
});
final Widget homeScreen;
@ -560,7 +559,7 @@ class _TabletHomeScreen extends StatelessWidget {
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: state.expanded ? Dimens.zero : homeScreenWidth,
width: homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: homeScreen,
@ -591,7 +590,7 @@ class _TabletHomeScreen extends StatelessWidget {
}
class _TabletStoryView extends StatelessWidget {
const _TabletStoryView({Key? key}) : super(key: key);
const _TabletStoryView();
@override
Widget build(BuildContext context) {
@ -604,7 +603,7 @@ class _TabletStoryView extends StatelessWidget {
}
return Material(
child: Container(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: const Center(
child: Text('Tap on story tile to view comments.'),

View File

@ -255,145 +255,151 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
},
),
],
child: BlocConsumer<CommentsCubit, CommentsState>(
child: BlocListener<CommentsCubit, CommentsState>(
listenWhen: (CommentsState previous, CommentsState current) =>
previous.status != current.status,
listener: (BuildContext context, CommentsState state) {
if (state.status == CommentsStatus.loaded) {
if (state.status != CommentsStatus.loading) {
refreshController
..refreshCompleted()
..loadComplete();
}
},
builder: (BuildContext context, CommentsState state) {
final Widget mainView = MainView(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController: commentEditingController,
authState: authState,
state: state,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
);
return BlocListener<EditCubit, EditState>(
listenWhen: (EditState previous, EditState current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited ||
commentEditingController.text != current.text;
},
listener: (BuildContext context, EditState editState) {
if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final String text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length),
);
}
} else {
child: BlocListener<EditCubit, EditState>(
listenWhen: (EditState previous, EditState current) {
return previous.replyingTo != current.replyingTo ||
previous.itemBeingEdited != current.itemBeingEdited ||
commentEditingController.text != current.text;
},
listener: (BuildContext context, EditState editState) {
if (editState.replyingTo != null ||
editState.itemBeingEdited != null) {
if (editState.text == null) {
commentEditingController.clear();
} else {
final String text = editState.text!;
commentEditingController
..text = text
..selection = TextSelection.fromPosition(
TextPosition(offset: text.length),
);
}
},
child: widget.splitViewEnabled
? Material(
child: Stack(
children: <Widget>[
Positioned.fill(
child: mainView,
} else {
commentEditingController.clear();
}
},
child: widget.splitViewEnabled
? Material(
child: Stack(
children: <Widget>[
Positioned.fill(
child: MainView(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController:
commentEditingController,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (
SplitViewState previous,
SplitViewState current,
) =>
previous.expanded != current.expanded,
builder: (
BuildContext context,
SplitViewState state,
) {
return Positioned(
top: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: CustomAppBar(
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap:
onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap:
context.read<SplitViewCubit>().zoom,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey:
fontSizeIconButtonKey,
),
);
),
BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (
SplitViewState previous,
SplitViewState current,
) =>
previous.expanded != current.expanded,
builder: (
BuildContext context,
SplitViewState state,
) {
return Positioned(
top: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: CustomAppBar(
backgroundColor: Theme.of(context)
.canvasColor
.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap:
context.read<SplitViewCubit>().zoom,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
);
},
),
Positioned(
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged:
context.read<EditCubit>().onTextChanged,
),
Positioned(
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged:
context.read<EditCubit>().onTextChanged,
),
),
],
),
)
: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
body: mainView,
bottomSheet: ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged: context.read<EditCubit>().onTextChanged,
),
),
],
),
);
},
)
: Scaffold(
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: CustomAppBar(
backgroundColor:
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
body: MainView(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController: commentEditingController,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
bottomSheet: ReplyBox(
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
focusNode.unfocus();
},
onChanged: context.read<EditCubit>().onTextChanged,
),
),
),
),
);
},
@ -442,7 +448,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
fontSize: fontSize.fontSize,
color:
context.read<PreferenceCubit>().state.fontSize == fontSize
? Colors.deepOrange
? Palette.deepOrange
: null,
),
),

View File

@ -7,10 +7,10 @@ import 'package:hacki/styles/styles.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
Key? key,
super.key,
required ScrollController scrollController,
required Item item,
required Color backgroundColor,
required Color super.backgroundColor,
required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss,
required VoidCallback onFontSizeTap,
@ -19,8 +19,6 @@ class CustomAppBar extends AppBar {
VoidCallback? onZoomTap,
bool? expanded,
}) : super(
key: key,
backgroundColor: backgroundColor,
elevation: Dimens.zero,
actions: <Widget>[
if (splitViewEnabled) ...<Widget>[

View File

@ -9,15 +9,15 @@ import 'package:hacki/utils/utils.dart';
class LoginDialog extends StatelessWidget {
const LoginDialog({
Key? key,
super.key,
required this.usernameController,
required this.passwordController,
required this.showSnackBar,
}) : super(key: key);
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final Function({
final void Function({
required String content,
VoidCallback? action,
String? label,

View File

@ -17,12 +17,11 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class MainView extends StatelessWidget {
const MainView({
Key? key,
super.key,
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
required this.authState,
required this.state,
required this.focusNode,
required this.topPadding,
required this.splitViewEnabled,
@ -30,424 +29,529 @@ class MainView extends StatelessWidget {
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
}) : super(key: key);
});
final ScrollController scrollController;
final RefreshController refreshController;
final TextEditingController commentEditingController;
final AuthState authState;
final CommentsState state;
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final Function(Item item, Rect? rect) onMoreTapped;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300;
static const double _trailingBoxHeight = 240;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned.fill(
child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (BuildContext context, CommentsState state) {
return SmartRefresher(
scrollController: scrollController,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Palette.orange,
offset: topPadding,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (BuildContext context, LoadStatus? mode) {
const double height = 55;
late final 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('');
}
return SizedBox(
height: height,
child: Center(child: body),
);
},
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.offlineReading) {
refreshController.refreshCompleted();
} else {
context.read<CommentsCubit>().refresh();
if (state.item.isPoll) {
context.read<PollCubit>().refresh();
}
}
},
onLoading: () {
if (state.fetchMode == FetchMode.eager) {
context.read<CommentsCubit>().loadMore();
} else {
refreshController.loadComplete();
}
},
child: ListView.builder(
primary: false,
itemCount: state.comments.length + 2,
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return _ParentItemSection(
scrollController: scrollController,
refreshController: refreshController,
commentEditingController: commentEditingController,
state: state,
authState: authState,
focusNode: focusNode,
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
);
} else if (index == state.comments.length + 1) {
if ((state.status == CommentsStatus.allLoaded &&
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFaces.pickRandomly()!),
),
);
} else {
return const SizedBox.shrink();
}
}
index = index - 1;
final Comment comment = state.comments.elementAt(index);
return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
comment: comment,
level: comment.level,
myUsername:
authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
},
),
);
},
),
),
Positioned(
height: Dimens.pt4,
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: BlocBuilder<CommentsCubit, CommentsState>(
buildWhen: (CommentsState prev, CommentsState current) =>
prev.status != current.status,
builder: (BuildContext context, CommentsState state) {
return AnimatedOpacity(
opacity: state.status == CommentsStatus.loading
? NumSwitch.on
: NumSwitch.off,
duration: const Duration(
milliseconds: _loadingIndicatorOpacityAnimationDuration,
),
child: const LinearProgressIndicator(),
);
},
),
)
],
);
}
}
class _ParentItemSection extends StatelessWidget {
const _ParentItemSection({
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
required this.state,
required this.authState,
required this.focusNode,
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
});
final ScrollController scrollController;
final RefreshController refreshController;
final TextEditingController commentEditingController;
final CommentsState state;
final AuthState authState;
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
@override
Widget build(BuildContext context) {
return SmartRefresher(
scrollController: scrollController,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Palette.orange,
offset: topPadding,
),
footer: CustomFooter(
loadStyle: LoadStyle.ShowWhenLoading,
builder: (BuildContext context, LoadStatus? mode) {
const double height = 55;
late final Widget body;
return Column(
children: <Widget>[
SizedBox(
height: topPadding,
),
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
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('');
}
return SizedBox(
height: height,
child: Center(child: body),
);
},
),
controller: refreshController,
onRefresh: () {
HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.offlineReading) {
refreshController.refreshCompleted();
} else {
context.read<CommentsCubit>().refresh();
if (state.item.isPoll) {
context.read<PollCubit>().refresh();
}
}
},
onLoading: () {
if (state.fetchMode == FetchMode.eager) {
context.read<CommentsCubit>().loadMore();
} else {
refreshController.loadComplete();
}
},
child: ListView.builder(
primary: false,
itemCount: state.comments.length + 2,
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return Column(
children: <Widget>[
SizedBox(
height: topPadding,
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
),
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
child: Row(
children: <Widget>[
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.postedDate,
style: const TextStyle(
color: Palette.grey,
),
),
],
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
),
child: Row(
children: <Widget>[
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader:
context.read<PreferenceCubit>().state.useReader,
offlineReading: context
.read<StoriesBloc>()
.state
.offlineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
const Spacer(),
Text(
state.item.postedDate,
style: const TextStyle(
color: Palette.grey,
),
),
],
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.useReader,
offlineReading: context
.read<StoriesBloc>()
.state
.offlineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
children: <TextSpan>[
TextSpan(
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
child: Text(
state.item.title,
textAlign: TextAlign.center,
),
if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
fontSize: MediaQuery.of(
context,
).textScaleFactor *
(prefState.fontSize.fontSize - 4),
color: Palette.orange,
),
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: PollView(
onLoginTapped: onLoginTapped,
],
),
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
style: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context).textScaleFactor *
context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: PollView(
onLoginTapped: onLoginTapped,
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
],
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(state.item as Story),
child: const Text('View all comments'),
),
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () => context
.read<CommentsCubit>()
.loadAll(state.item as Story),
child: const Text('View all comments'),
),
Text(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
Text(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
),
],
const Spacer(),
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed:
context.read<CommentsCubit>().loadParentThread,
child:
state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
),
],
const Spacer(),
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
.toList(),
onChanged:
context.read<CommentsCubit>().onFetchModeChanged,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) =>
DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
),
),
],
],
);
} else if (index == state.comments.length + 1) {
if ((state.status == CommentsStatus.allLoaded &&
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: 240,
child: Center(
child: Text(Constants.happyFaces.pickRandomly()!),
),
);
} else {
return const SizedBox.shrink();
}
}
index = index - 1;
final Comment comment = state.comments.elementAt(index);
return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile(
comment: comment,
level: comment.level,
myUsername: authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
if (cmt.id != context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(cmt);
focusNode.requestFocus();
},
onEditTapped: (Comment cmt) {
HapticFeedback.lightImpact();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt);
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
)
.toList(),
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
),
);
},
),
),
],
],
);
}
}

View File

@ -12,17 +12,17 @@ import 'package:hacki/utils/utils.dart';
class MorePopupMenu extends StatelessWidget {
const MorePopupMenu({
Key? key,
super.key,
required this.item,
required this.isBlocked,
required this.showSnackBar,
required this.onStoryLinkTapped,
required this.onLoginTapped,
}) : super(key: key);
});
final Item item;
final bool isBlocked;
final Function({
final void Function({
required String content,
VoidCallback? action,
String? label,

View File

@ -168,7 +168,7 @@ class PollView extends StatelessWidget {
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyText1?.color,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,

View File

@ -111,7 +111,8 @@ class _ReplyBoxState extends State<ReplyBox> {
...<Widget>[
if (replyingTo != null)
AnimatedOpacity(
opacity: expanded ? 1 : 0,
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),

View File

@ -9,19 +9,19 @@ import 'package:responsive_builder/responsive_builder.dart';
class TimeMachineDialog extends StatelessWidget {
const TimeMachineDialog({
Key? key,
super.key,
required this.comment,
required this.size,
required this.deviceType,
required this.widthFactor,
required this.onStoryLinkTapped,
}) : super(key: key);
});
final Comment comment;
final Size size;
final DeviceScreenType deviceType;
final double widthFactor;
final Function(String) onStoryLinkTapped;
final void Function(String) onStoryLinkTapped;
@override
Widget build(BuildContext context) {

View File

@ -123,6 +123,7 @@ class _ProfileScreenState extends State<ProfileScreen>
return ItemsListView<Item>(
showWebPreview: false,
showMetadata: false,
showUrl: false,
useConsistentFontSize: true,
refreshController: refreshControllerHistory,
items: historyState.submittedItems
@ -175,6 +176,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showWebPreview:
preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
useCommentTile: true,
refreshController: refreshControllerFav,
items: favState.favItems,
@ -375,6 +377,17 @@ class _ProfileScreenState extends State<ProfileScreen>
),
],
),
const Divider(),
StoryTile(
showWebPreview:
preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
story: Story.placeholder(),
onTap: () =>
LinkUtil.launch(Constants.guidelineLink),
),
const Divider(),
for (final Preference<dynamic> preference
in preferenceState.preferences
.whereType<BooleanPreference>()
@ -637,89 +650,93 @@ class _ProfileScreenState extends State<ProfileScreen>
final PackageInfo packageInfo = await PackageInfo.fromPlatform();
final String version = packageInfo.version;
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v$version',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(
Dimens.pt12,
if (mounted) {
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v$version',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(
Dimens.pt12,
),
),
child: Image.asset(
Constants.hackiIconPath,
height: Dimens.pt50,
width: Dimens.pt50,
),
),
child: Image.asset(
Constants.hackiIconPath,
height: Dimens.pt50,
width: Dimens.pt50,
),
),
children: <Widget>[
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.portfolioLink,
children: <Widget>[
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.portfolioLink,
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.addressCard,
),
SizedBox(
width: Dimens.pt12,
),
Text('Developer'),
],
),
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.addressCard,
),
SizedBox(
width: Dimens.pt12,
),
Text('Developer'),
],
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.githubLink,
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.github,
),
SizedBox(
width: Dimens.pt12,
),
Text('Source code'),
],
),
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.githubLink,
ElevatedButton(
onPressed: () => LinkUtil.launch(
Platform.isIOS
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const <Widget>[
Icon(
Icons.thumb_up,
),
SizedBox(
width: Dimens.pt12,
),
Text('Like the app?'),
],
),
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.github,
),
SizedBox(
width: Dimens.pt12,
),
Text('Source code'),
],
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
Icon(
FeatherIcons.coffee,
),
SizedBox(
width: Dimens.pt12,
),
Text('Buy me a coffee'),
],
),
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Platform.isIOS ? Constants.appStoreLink : Constants.googlePlayLink,
),
child: Row(
children: const <Widget>[
Icon(
Icons.thumb_up,
),
SizedBox(
width: Dimens.pt12,
),
Text('Like the app?'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
Icon(
FeatherIcons.coffee,
),
SizedBox(
width: Dimens.pt12,
),
Text('Buy me a coffee'),
],
),
),
],
);
],
);
}
}
void onCommentTapped(Comment comment, {VoidCallback? then}) {

View File

@ -22,7 +22,7 @@ class InboxView extends StatelessWidget {
final RefreshController refreshController;
final List<Comment> comments;
final List<int> unreadCommentsIds;
final Function(Comment) onCommentTapped;
final void Function(Comment) onCommentTapped;
final VoidCallback onMarkAllAsReadTapped;
final VoidCallback onLoadMore;
final VoidCallback onRefresh;

View File

@ -166,6 +166,7 @@ class _SearchScreenState extends State<SearchScreen> {
showWebPreview:
prefState.showComplexStoryTile,
showMetadata: prefState.showMetadata,
showUrl: prefState.showUrl,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
@ -178,8 +179,7 @@ class _SearchScreenState extends State<SearchScreen> {
),
],
)
.expand((List<Widget> e) => e)
.toList(),
.expand((List<Widget> e) => e),
const SizedBox(
height: Dimens.pt40,
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hacki/screens/widgets/widgets.dart';
typedef DateRangeCallback = Function(DateTime, DateTime);
typedef DateRangeCallback = void Function(DateTime, DateTime);
enum CustomDateTimeRange {
pastDay(Duration(days: 1), label: 'past day'),
@ -17,10 +17,10 @@ enum CustomDateTimeRange {
class CustomRangeFilterChip extends StatelessWidget {
const CustomRangeFilterChip({
Key? key,
super.key,
required this.range,
required this.onTap,
}) : super(key: key);
});
final CustomDateTimeRange range;
final DateRangeCallback onTap;

View File

@ -5,14 +5,14 @@ import 'package:intl/intl.dart';
class DateTimeRangeFilterChip extends StatelessWidget {
const DateTimeRangeFilterChip({
Key? key,
super.key,
required this.filter,
required this.onDateTimeRangeUpdated,
required this.onDateTimeRangeRemoved,
}) : super(key: key);
});
final DateTimeRangeFilter? filter;
final Function(DateTime, DateTime) onDateTimeRangeUpdated;
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final VoidCallback onDateTimeRangeRemoved;
static final DateFormat _dateTimeFormatter = DateFormat.yMMMd();

View File

@ -4,9 +4,9 @@ import 'package:hacki/screens/widgets/widgets.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
Key? key,
super.key,
required this.filter,
}) : super(key: key);
});
final PostedByFilter? filter;

View File

@ -17,7 +17,7 @@ class BlocBuilder3<
BlocC extends StateStreamable<BlocCState>,
BlocCState> extends StatelessWidget {
const BlocBuilder3({
Key? key,
super.key,
required this.builder,
this.blocA,
this.blocB,
@ -25,7 +25,7 @@ class BlocBuilder3<
this.buildWhenA,
this.buildWhenB,
this.buildWhenC,
}) : super(key: key);
});
final BlocWidgetBuilder3<BlocAState, BlocBState, BlocCState> builder;

View File

@ -33,11 +33,11 @@ class CommentTile extends StatelessWidget {
final Comment comment;
final int level;
final bool actionable;
final Function(Comment)? onReplyTapped;
final Function(Comment, Rect?)? onMoreTapped;
final Function(Comment)? onEditTapped;
final Function(Comment)? onRightMoreTapped;
final Function(String) onStoryLinkTapped;
final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
@override

View File

@ -16,6 +16,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
super.key,
required this.showWebPreview,
required this.showMetadata,
required this.showUrl,
required this.items,
required this.onTap,
required this.refreshController,
@ -39,6 +40,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final bool showCommentBy;
final bool showWebPreview;
final bool showMetadata;
final bool showUrl;
final bool enablePullDown;
final bool markReadStories;
final bool showOfflineBanner;
@ -55,7 +57,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final VoidCallback? onRefresh;
final VoidCallback? onLoadMore;
final ValueChanged<Story>? onPinned;
final Function(T) onTap;
final void Function(T) onTap;
@override
Widget build(BuildContext context) {
@ -97,6 +99,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
onTap: () => onTap(e),
showWebPreview: showWebPreview,
showMetadata: showMetadata,
showUrl: showUrl,
hasRead: markReadStories && hasRead,
simpleTileFontSize: useConsistentFontSize
? TextDimens.pt14
@ -246,11 +249,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
class _CommentTile extends StatelessWidget {
const _CommentTile({
Key? key,
required this.comment,
required this.onTap,
this.fontSize = 16,
}) : super(key: key);
});
final Comment comment;
final VoidCallback onTap;

View File

@ -15,6 +15,7 @@ class LinkPreview extends StatefulWidget {
required this.link,
required this.story,
required this.showMetadata,
required this.showUrl,
required this.offlineReading,
this.cache = const Duration(days: 30),
this.titleStyle,
@ -103,6 +104,7 @@ class LinkPreview extends StatefulWidget {
final List<BoxShadow>? boxShadow;
final bool showMetadata;
final bool showUrl;
final bool offlineReading;
@override
@ -111,7 +113,8 @@ class LinkPreview extends StatefulWidget {
class _LinkPreviewState extends State<LinkPreview> {
InfoBase? _info;
String? _errorTitle, _errorBody;
String? _errorTitle;
String? _errorBody;
bool _loading = false;
@override
@ -156,7 +159,7 @@ class _LinkPreviewState extends State<LinkPreview> {
}
Widget _buildLinkContainer(
double _height, {
double height, {
String? title = '',
String? desc = '',
String? imageUri = '',
@ -175,11 +178,12 @@ class _LinkPreviewState extends State<LinkPreview> {
const BoxShadow(blurRadius: 3, color: Palette.grey),
],
),
height: _height,
height: height,
child: LinkView(
key: widget.key ?? Key(widget.link),
metadata: widget.story.simpleMetadata,
url: widget.link,
readableUrl: widget.story.readableUrl,
title: widget.story.title,
description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri,
@ -194,6 +198,7 @@ class _LinkPreviewState extends State<LinkPreview> {
bgColor: widget.backgroundColor,
radius: widget.borderRadius ?? 12,
showMetadata: widget.showMetadata,
showUrl: widget.showUrl,
),
);
}

View File

@ -8,10 +8,12 @@ class LinkView extends StatelessWidget {
super.key,
required this.metadata,
required this.url,
required this.readableUrl,
required this.title,
required this.description,
required this.onTap,
required this.showMetadata,
required this.showUrl,
this.imageUri,
this.imagePath,
this.titleTextStyle,
@ -30,11 +32,12 @@ class LinkView extends StatelessWidget {
final String metadata;
final String url;
final String readableUrl;
final String title;
final String description;
final String? imageUri;
final String? imagePath;
final Function(String) onTap;
final void Function(String) onTap;
final TextStyle? titleTextStyle;
final TextStyle? bodyTextStyle;
final bool showMultiMedia;
@ -44,6 +47,7 @@ class LinkView extends StatelessWidget {
final double radius;
final Color? bgColor;
final bool showMetadata;
final bool showUrl;
double computeTitleFontSize(double width) {
double size = width * 0.13;
@ -72,13 +76,13 @@ class LinkView extends StatelessWidget {
final double layoutWidth = constraints.biggest.width;
final double layoutHeight = constraints.biggest.height;
final TextStyle _titleFontSize = titleTextStyle ??
final TextStyle titleFontSize = titleTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth),
color: Palette.black,
fontWeight: FontWeight.bold,
);
final TextStyle _bodyFontSize = bodyTextStyle ??
final TextStyle bodyFontSize = bodyTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth) - 1,
color: Palette.grey,
@ -127,11 +131,11 @@ class LinkView extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildTitleContainer(
_titleFontSize,
titleFontSize,
computeTitleLines(layoutHeight),
),
_buildBodyContainer(
_bodyFontSize,
bodyFontSize,
computeBodyLines(layoutHeight),
)
],
@ -145,7 +149,8 @@ class LinkView extends StatelessWidget {
);
}
Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) {
Widget _buildTitleContainer(TextStyle titleTS, int maxLines) {
final bool showUrl = this.showUrl && url.isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
child: Column(
@ -154,17 +159,33 @@ class LinkView extends StatelessWidget {
alignment: Alignment.topLeft,
child: Text(
title,
style: _titleTS,
style: titleTS,
overflow: TextOverflow.ellipsis,
maxLines: _maxLines,
maxLines: maxLines,
),
),
if (showUrl)
Container(
alignment: Alignment.topLeft,
child: Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: titleTS.copyWith(
color: Palette.grey,
fontSize:
titleTS.fontSize == null ? 12 : titleTS.fontSize! - 4,
fontWeight: FontWeight.w400,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
);
}
Widget _buildBodyContainer(TextStyle _bodyTS, int _maxLines) {
Widget _buildBodyContainer(TextStyle bodyTS, int maxLines) {
return Expanded(
flex: 2,
child: Padding(
@ -177,9 +198,9 @@ class LinkView extends StatelessWidget {
child: Text(
metadata,
textAlign: TextAlign.left,
style: _bodyTS.copyWith(
style: bodyTS.copyWith(
fontSize:
_bodyTS.fontSize == null ? 12 : _bodyTS.fontSize! - 2,
bodyTS.fontSize == null ? 12 : bodyTS.fontSize! - 2,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
@ -191,10 +212,11 @@ class LinkView extends StatelessWidget {
child: Text(
description,
textAlign: TextAlign.left,
style: _bodyTS,
style: bodyTS,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines:
(bodyMaxLines ?? _maxLines) - (showMetadata ? 1 : 0),
maxLines: (bodyMaxLines ?? maxLines) -
(showMetadata ? 1 : 0) -
(showUrl && url.isNotEmpty ? 1 : 0),
),
),
),

View File

@ -63,7 +63,7 @@ class WebImageInfo extends InfoBase {
/// Video Information
class WebVideoInfo extends WebImageInfo {
WebVideoInfo({String? image}) : super(image: image);
WebVideoInfo({super.image});
}
/// Web analyzer

View File

@ -6,7 +6,7 @@ import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class OnboardingView extends StatefulWidget {
const OnboardingView({Key? key}) : super(key: key);
const OnboardingView({super.key});
@override
State<OnboardingView> createState() => _OnboardingViewState();
@ -106,10 +106,9 @@ class _OnboardingViewState extends State<OnboardingView> {
class _PageViewChild extends StatelessWidget {
const _PageViewChild({
Key? key,
required this.path,
required this.description,
}) : super(key: key);
});
final String path;
final String description;

View File

@ -68,6 +68,7 @@ class _StoriesListViewState extends State<StoriesListView> {
context.read<PreferenceCubit>().state.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
refreshController: refreshController,
items: state.storiesByType[storyType]!,
onRefresh: () {

View File

@ -15,6 +15,7 @@ class StoryTile extends StatelessWidget {
this.hasRead = false,
required this.showWebPreview,
required this.showMetadata,
required this.showUrl,
required this.story,
required this.onTap,
this.simpleTileFontSize = 16,
@ -22,6 +23,7 @@ class StoryTile extends StatelessWidget {
final bool showWebPreview;
final bool showMetadata;
final bool showUrl;
final bool hasRead;
final Story story;
final VoidCallback onTap;
@ -54,10 +56,11 @@ class StoryTile extends StatelessWidget {
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.subtitle1?.color,
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
),
),
),
@ -76,11 +79,31 @@ class StoryTile extends StatelessWidget {
Row(
children: <Widget>[
Expanded(
child: Text(
story.title,
style: TextStyle(
color: hasRead ? Palette.grey[500] : null,
fontSize: simpleTileFontSize,
child: RichText(
textScaleFactor: MediaQuery.of(context).textScaleFactor,
text: TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
style: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context)
.textTheme
.bodyLarge
?.color,
fontSize: simpleTileFontSize,
),
),
if (showUrl && story.url.isNotEmpty)
TextSpan(
text: ' (${story.readableUrl})',
style: TextStyle(
color: Palette.grey[500],
fontSize: simpleTileFontSize - 4,
),
),
],
),
),
),
@ -114,9 +137,8 @@ class StoryTile extends StatelessWidget {
class _LinkPreviewPlaceholder extends StatelessWidget {
const _LinkPreviewPlaceholder({
Key? key,
required this.height,
}) : super(key: key);
});
final double height;

View File

@ -6,7 +6,6 @@ export 'custom_chip.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 'onboarding_view.dart';
export 'spring_curve.dart';

View File

@ -38,3 +38,8 @@ abstract class TextDimens {
static const double pt26 = 26;
static const double pt36 = 36;
}
abstract class NumSwitch {
static const double on = 1;
static const double off = 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
name: hacki
description: A Hacker News reader.
version: 1.0.1+79
version: 1.0.3+81
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.3.10"
flutter: "3.7.0"
dependencies:
adaptive_theme: ^3.0.0
@ -73,12 +73,14 @@ dependencies:
dev_dependencies:
bloc_test: ^9.1.0
flutter_driver:
sdk: flutter
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
mocktail: ^0.3.0
very_good_analysis: ^2.4.0
very_good_analysis: ^3.1.0
flutter:
uses-material-design: true

View File

@ -20,8 +20,11 @@ void main() {
final MockStoriesRepository mockStoriesRepository = MockStoriesRepository();
final MockSembastRepository mockSembastRepository = MockSembastRepository();
const int created = 0, delay = 1, karma = 2;
const String about = 'about', id = 'id';
const int created = 0;
const int delay = 1;
const int karma = 2;
const String about = 'about';
const String id = 'id';
const User tUser = User(
about: about,
@ -57,7 +60,8 @@ void main() {
);
group('AuthAppStarted', () {
const String username = 'username', password = 'password';
const String username = 'username';
const String password = 'password';
setUp(() {
when(() => mockAuthRepository.username)
.thenAnswer((_) => Future<String?>.value(username));