Compare commits

...

8 Commits

18 changed files with 190 additions and 120 deletions

View File

@ -41,7 +41,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
await _authRepository.loggedIn.then((bool loggedIn) async { await _authRepository.loggedIn.then((bool loggedIn) async {
if (loggedIn) { if (loggedIn) {
final String? username = await _authRepository.username; final String? username = await _authRepository.username;
final User user = await _storiesRepository.fetchUser(id: username!); User? user = await _storiesRepository.fetchUser(id: username!);
/// According to Hacker News' API documentation,
/// if user has no public activity (posting a comment or story),
/// then it will not be available from the API.
user ??= User.emptyWithId(username);
emit( emit(
state.copyWith( state.copyWith(
@ -84,10 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (successful) { if (successful) {
final User user = await _storiesRepository.fetchUser(id: event.username); final User? user = await _storiesRepository.fetchUser(id: event.username);
emit( emit(
state.copyWith( state.copyWith(
user: user, user: user ?? User.emptyWithId(event.username),
isLoggedIn: true, isLoggedIn: true,
status: AuthStatus.loaded, status: AuthStatus.loaded,
), ),

View File

@ -7,7 +7,7 @@ abstract class Constants {
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md'; 'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
static const String hackerNewsLogoLink = static const String hackerNewsLogoLink =
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png'; 'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
static const String portfolioLink = 'https://livinglist.github.io'; static const String portfolioLink = 'https://github.com/Livinglist';
static const String githubLink = 'https://github.com/Livinglist/Hacki'; static const String githubLink = 'https://github.com/Livinglist/Hacki';
static const String appStoreLink = static const String appStoreLink =
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review'; 'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';

View File

@ -52,8 +52,6 @@ class PreferenceState extends Equatable {
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>(); bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get webFirstEnabled => _isOn<NavigationModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>(); bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>(); bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();

View File

@ -16,8 +16,13 @@ class UserCubit extends Cubit<UserState> {
void init({required String userId}) { void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading)); emit(state.copyWith(status: UserStatus.loading));
_storiesRepository.fetchUser(id: userId).then((User user) { _storiesRepository.fetchUser(id: userId).then((User? user) {
emit(state.copyWith(user: user, status: UserStatus.loaded)); emit(
state.copyWith(
user: user ?? User.emptyWithId(userId),
status: UserStatus.loaded,
),
);
}).onError((_, __) { }).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure)); emit(state.copyWith(status: UserStatus.failure));
return; return;

View File

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

View File

@ -31,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(), const NotificationModePreference(),
const SwipeGesturePreference(), const SwipeGesturePreference(),
const CollapseModePreference(), const CollapseModePreference(),
const NavigationModePreference(),
const ReaderModePreference(), const ReaderModePreference(),
const MarkReadStoriesModePreference(), const MarkReadStoriesModePreference(),
const EyeCandyModePreference(), const EyeCandyModePreference(),
@ -54,7 +53,6 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true; const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false; const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true; const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValue = false;
const bool _eyeCandyModeDefaultValue = false; const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false; const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true; const bool _readerModeDefaultValue = true;
@ -189,29 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
String get subtitle => '''show url in story tile.'''; String get subtitle => '''show url in story tile.''';
} }
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {
const NavigationModePreference({bool? val})
: super(
val: val ?? _navigationModeDefaultValue,
);
@override
NavigationModePreference copyWith({required bool? val}) {
return NavigationModePreference(val: val);
}
@override
String get key => 'navigationMode';
@override
String get title => 'Show Web Page First';
@override
String get subtitle => '''show web page first after tapping on story.''';
}
class ReaderModePreference extends BooleanPreference { class ReaderModePreference extends BooleanPreference {
const ReaderModePreference({bool? val}) const ReaderModePreference({bool? val})
: super(val: val ?? _readerModeDefaultValue); : super(val: val ?? _readerModeDefaultValue);

View File

@ -17,6 +17,12 @@ class User extends Equatable {
id = '', id = '',
karma = 0; karma = 0;
const User.emptyWithId(this.id)
: about = '',
created = 0,
delay = 0,
karma = 0;
User.fromJson(Map<String, dynamic> json) User.fromJson(Map<String, dynamic> json)
: about = json['about'] as String? ?? '', : about = json['about'] as String? ?? '',
created = json['created'] as int? ?? 0, created = json['created'] as int? ?? 0,

View File

@ -74,11 +74,14 @@ class StoriesRepository {
/// Fetch a [User] by its [id]. /// Fetch a [User] by its [id].
/// Hacker News uses user's username as [id]. /// Hacker News uses user's username as [id].
Future<User> fetchUser({required String id}) async { Future<User?> fetchUser({required String id}) async {
final User user = await _firebaseClient final User? user = await _firebaseClient
.get('${_baseUrl}user/$id.json') .get('${_baseUrl}user/$id.json')
.then((dynamic val) { .then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>; final Map<String, dynamic>? json = val as Map<String, dynamic>?;
if (json == null) return null;
final User user = User.fromJson(json); final User user = User.fromJson(json);
return user; return user;
}); });

View File

@ -210,12 +210,9 @@ class _HomeScreenState extends State<HomeScreen>
} }
void onStoryTapped(Story story, {bool isPin = false}) { void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled; final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading = final bool offlineReading =
context.read<StoriesBloc>().state.isOfflineReading; context.read<StoriesBloc>().state.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled; final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
// If a story is a job story and it has a link to the job posting, // If a story is a job story and it has a link to the job posting,
@ -245,7 +242,7 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) { if (story.url.isNotEmpty && isJobWithLink) {
LinkUtil.launch( LinkUtil.launch(
story.url, story.url,
useReader: useReader, useReader: useReader,

View File

@ -372,22 +372,19 @@ class _SettingsState extends State<Settings> {
RadioListTile<AdaptiveThemeMode>( RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.light, value: AdaptiveThemeMode.light,
groupValue: themeMode, groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) => onChanged: updateThemeSetting,
AdaptiveTheme.of(context).setLight(),
title: const Text('Light'), title: const Text('Light'),
), ),
RadioListTile<AdaptiveThemeMode>( RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.dark, value: AdaptiveThemeMode.dark,
groupValue: themeMode, groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) => onChanged: updateThemeSetting,
AdaptiveTheme.of(context).setDark(),
title: const Text('Dark'), title: const Text('Dark'),
), ),
RadioListTile<AdaptiveThemeMode>( RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.system, value: AdaptiveThemeMode.system,
groupValue: themeMode, groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) => onChanged: updateThemeSetting,
AdaptiveTheme.of(context).setSystem(),
title: const Text('System'), title: const Text('System'),
), ),
], ],
@ -397,6 +394,24 @@ class _SettingsState extends State<Settings> {
); );
} }
void updateThemeSetting(AdaptiveThemeMode? val) {
switch (val) {
case AdaptiveThemeMode.light:
AdaptiveTheme.of(context).setLight();
break;
case AdaptiveThemeMode.dark:
AdaptiveTheme.of(context).setDark();
break;
case AdaptiveThemeMode.system:
case null:
AdaptiveTheme.of(context).setSystem();
break;
}
final Brightness brightness = Theme.of(context).brightness;
ThemeUtil.updateAndroidStatusBarSetting(brightness, val);
}
void showClearCacheDialog() { void showClearCacheDialog() {
showDialog<void>( showDialog<void>(
context: context, context: context,

View File

@ -183,7 +183,7 @@ class CommentTile extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: Dimens.pt8, left: Dimens.pt8,
right: Dimens.pt8, right: Dimens.pt2,
top: Dimens.pt6, top: Dimens.pt6,
bottom: Dimens.pt12, bottom: Dimens.pt12,
), ),

View File

@ -7,13 +7,13 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart'; import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkPreview extends StatefulWidget { class LinkPreview extends StatefulWidget {
const LinkPreview({ const LinkPreview({
super.key, super.key,
required this.link, required this.link,
required this.story, required this.story,
required this.onTap,
required this.showMetadata, required this.showMetadata,
required this.showUrl, required this.showUrl,
required this.isOfflineReading, required this.isOfflineReading,
@ -34,6 +34,7 @@ class LinkPreview extends StatefulWidget {
}); });
final Story story; final Story story;
final VoidCallback onTap;
/// Web address (Url that need to be parsed) /// Web address (Url that need to be parsed)
/// For IOS & Web, only HTTP and HTTPS are support /// For IOS & Web, only HTTP and HTTPS are support
@ -141,19 +142,6 @@ class _LinkPreviewState extends State<LinkPreview> {
} }
} }
Future<void> _launchURL(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
try {
await launchUrl(uri);
} catch (err) {
throw Exception('Could not launch $url. Error: $err');
}
}
}
Widget _buildLinkContainer( Widget _buildLinkContainer(
double height, { double height, {
String? title = '', String? title = '',
@ -184,7 +172,7 @@ class _LinkPreviewState extends State<LinkPreview> {
description: desc ?? title ?? 'no comment yet.', description: desc ?? title ?? 'no comment yet.',
imageUri: imageUri, imageUri: imageUri,
imagePath: Constants.hackerNewsLogoPath, imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL, onTap: widget.onTap,
titleTextStyle: widget.titleStyle, titleTextStyle: widget.titleStyle,
bodyTextOverflow: widget.bodyTextOverflow, bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines, bodyMaxLines: widget.bodyMaxLines,

View File

@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/models/models.dart'; import 'package:hacki/screens/widgets/link_preview/models/models.dart';
import 'package:hacki/screens/widgets/tap_down_wrapper.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
class LinkView extends StatelessWidget { class LinkView extends StatelessWidget {
LinkView({ LinkView({
@ -41,7 +43,7 @@ class LinkView extends StatelessWidget {
final String description; final String description;
final String? imageUri; final String? imageUri;
final String? imagePath; final String? imagePath;
final void Function(String) onTap; final VoidCallback onTap;
final TextStyle titleTextStyle; final TextStyle titleTextStyle;
final bool showMultiMedia; final bool showMultiMedia;
final TextOverflow? bodyTextOverflow; final TextOverflow? bodyTextOverflow;
@ -176,9 +178,7 @@ class LinkView extends StatelessWidget {
titleStyle, titleStyle,
); );
return InkWell( return Row(
onTap: () => onTap(url),
child: Row(
children: <Widget>[ children: <Widget>[
if (showMultiMedia) if (showMultiMedia)
Padding( Padding(
@ -187,6 +187,17 @@ class LinkView extends StatelessWidget {
top: 5, top: 5,
bottom: 5, bottom: 5,
), ),
child: TapDownWrapper(
onTap: () {
if (url.isNotEmpty) {
LinkUtil.launch(
url,
useHackiForHnLink: false,
);
} else {
onTap();
}
},
child: SizedBox( child: SizedBox(
height: layoutHeight, height: layoutHeight,
width: layoutHeight, width: layoutHeight,
@ -207,10 +218,13 @@ class LinkView extends StatelessWidget {
}, },
), ),
), ),
),
) )
else else
const SizedBox(width: Dimens.pt5), const SizedBox(width: Dimens.pt5),
SizedBox( TapDownWrapper(
onTap: onTap,
child: SizedBox(
height: layoutHeight, height: layoutHeight,
width: layoutWidth - layoutHeight - 8, width: layoutWidth - layoutHeight - 8,
child: Column( child: Column(
@ -258,8 +272,8 @@ class LinkView extends StatelessWidget {
], ],
), ),
), ),
],
), ),
],
); );
}, },
); );

View File

@ -37,13 +37,10 @@ class StoryTile extends StatelessWidget {
return Semantics( return Semantics(
label: story.screenReaderLabel, label: story.screenReaderLabel,
excludeSemantics: true, excludeSemantics: true,
child: TapDownWrapper(
onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12, horizontal: Dimens.pt12,
), ),
child: AbsorbPointer(
child: LinkPreview( child: LinkPreview(
story: story, story: story,
link: story.url, link: story.url,
@ -66,8 +63,7 @@ class StoryTile extends StatelessWidget {
), ),
showMetadata: showMetadata, showMetadata: showMetadata,
showUrl: showUrl, showUrl: showUrl,
), onTap: onTap,
),
), ),
), ),
); );

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

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

View File

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

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.4.0+104 version: 1.4.3+107
publish_to: none publish_to: none
environment: environment: