mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
ab4051c018 | |||
c230c21218 | |||
c24e12237e | |||
e15dcba93b | |||
1362b93a74 | |||
ac18793f98 | |||
e52f65c773 | |||
06212a0d72 |
@ -41,7 +41,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((bool loggedIn) async {
|
||||
if (loggedIn) {
|
||||
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(
|
||||
state.copyWith(
|
||||
@ -84,10 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (successful) {
|
||||
final User user = await _storiesRepository.fetchUser(id: event.username);
|
||||
final User? user = await _storiesRepository.fetchUser(id: event.username);
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user,
|
||||
user: user ?? User.emptyWithId(event.username),
|
||||
isLoggedIn: true,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
|
@ -7,7 +7,7 @@ abstract class Constants {
|
||||
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
|
||||
static const String hackerNewsLogoLink =
|
||||
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
|
||||
static const String portfolioLink = 'https://livinglist.github.io';
|
||||
static const String portfolioLink = 'https://github.com/Livinglist';
|
||||
static const String githubLink = 'https://github.com/Livinglist/Hacki';
|
||||
static const String appStoreLink =
|
||||
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
|
||||
|
@ -52,8 +52,6 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
|
||||
|
||||
bool get webFirstEnabled => _isOn<NavigationModePreference>();
|
||||
|
||||
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
|
||||
|
||||
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
@ -16,8 +16,13 @@ class UserCubit extends Cubit<UserState> {
|
||||
|
||||
void init({required String userId}) {
|
||||
emit(state.copyWith(status: UserStatus.loading));
|
||||
_storiesRepository.fetchUser(id: userId).then((User user) {
|
||||
emit(state.copyWith(user: user, status: UserStatus.loaded));
|
||||
_storiesRepository.fetchUser(id: userId).then((User? user) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(userId),
|
||||
status: UserStatus.loaded,
|
||||
),
|
||||
);
|
||||
}).onError((_, __) {
|
||||
emit(state.copyWith(status: UserStatus.failure));
|
||||
return;
|
||||
|
@ -21,6 +21,7 @@ import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/services/custom_bloc_observer.dart';
|
||||
import 'package:hacki/services/fetcher.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/theme_util.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
@ -123,14 +124,6 @@ Future<void> main({bool testing = false}) async {
|
||||
systemNavigationBarDividerColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarBrightness: Brightness.light,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
@ -147,7 +140,11 @@ Future<void> main({bool testing = false}) async {
|
||||
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
|
||||
);
|
||||
|
||||
Bloc.observer = CustomBlocObserver();
|
||||
// ignore: prefer_asserts_with_message
|
||||
assert(() {
|
||||
Bloc.observer = CustomBlocObserver();
|
||||
return true;
|
||||
}());
|
||||
|
||||
HydratedBloc.storage = storage;
|
||||
|
||||
@ -276,6 +273,10 @@ class HackiApp extends StatelessWidget {
|
||||
AsyncSnapshot<AdaptiveThemeMode?> snapshot,
|
||||
) {
|
||||
final AdaptiveThemeMode? mode = snapshot.data;
|
||||
ThemeUtil.updateAndroidStatusBarSetting(
|
||||
Theme.of(context).brightness,
|
||||
mode,
|
||||
);
|
||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen:
|
||||
(PreferenceState previous, PreferenceState current) =>
|
||||
|
@ -31,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const NotificationModePreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const CollapseModePreference(),
|
||||
const NavigationModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const MarkReadStoriesModePreference(),
|
||||
const EyeCandyModePreference(),
|
||||
@ -54,7 +53,6 @@ abstract class IntPreference extends Preference<int> {
|
||||
const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _navigationModeDefaultValue = false;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _trueDarkModeDefaultValue = false;
|
||||
const bool _readerModeDefaultValue = true;
|
||||
@ -189,29 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
|
||||
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 {
|
||||
const ReaderModePreference({bool? val})
|
||||
: super(val: val ?? _readerModeDefaultValue);
|
||||
|
@ -17,6 +17,12 @@ class User extends Equatable {
|
||||
id = '',
|
||||
karma = 0;
|
||||
|
||||
const User.emptyWithId(this.id)
|
||||
: about = '',
|
||||
created = 0,
|
||||
delay = 0,
|
||||
karma = 0;
|
||||
|
||||
User.fromJson(Map<String, dynamic> json)
|
||||
: about = json['about'] as String? ?? '',
|
||||
created = json['created'] as int? ?? 0,
|
||||
|
@ -74,11 +74,14 @@ class StoriesRepository {
|
||||
|
||||
/// Fetch a [User] by its [id].
|
||||
/// Hacker News uses user's username as [id].
|
||||
Future<User> fetchUser({required String id}) async {
|
||||
final User user = await _firebaseClient
|
||||
Future<User?> fetchUser({required String id}) async {
|
||||
final User? user = await _firebaseClient
|
||||
.get('${_baseUrl}user/$id.json')
|
||||
.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);
|
||||
return user;
|
||||
});
|
||||
|
@ -210,12 +210,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
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 offlineReading =
|
||||
context.read<StoriesBloc>().state.isOfflineReading;
|
||||
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
|
||||
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
|
||||
|
||||
// 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(
|
||||
story.url,
|
||||
useReader: useReader,
|
||||
|
@ -372,22 +372,19 @@ class _SettingsState extends State<Settings> {
|
||||
RadioListTile<AdaptiveThemeMode>(
|
||||
value: AdaptiveThemeMode.light,
|
||||
groupValue: themeMode,
|
||||
onChanged: (AdaptiveThemeMode? val) =>
|
||||
AdaptiveTheme.of(context).setLight(),
|
||||
onChanged: updateThemeSetting,
|
||||
title: const Text('Light'),
|
||||
),
|
||||
RadioListTile<AdaptiveThemeMode>(
|
||||
value: AdaptiveThemeMode.dark,
|
||||
groupValue: themeMode,
|
||||
onChanged: (AdaptiveThemeMode? val) =>
|
||||
AdaptiveTheme.of(context).setDark(),
|
||||
onChanged: updateThemeSetting,
|
||||
title: const Text('Dark'),
|
||||
),
|
||||
RadioListTile<AdaptiveThemeMode>(
|
||||
value: AdaptiveThemeMode.system,
|
||||
groupValue: themeMode,
|
||||
onChanged: (AdaptiveThemeMode? val) =>
|
||||
AdaptiveTheme.of(context).setSystem(),
|
||||
onChanged: updateThemeSetting,
|
||||
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() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
|
@ -183,7 +183,7 @@ class CommentTile extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Dimens.pt8,
|
||||
right: Dimens.pt8,
|
||||
right: Dimens.pt2,
|
||||
top: Dimens.pt6,
|
||||
bottom: Dimens.pt12,
|
||||
),
|
||||
|
@ -7,13 +7,13 @@ import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/link_preview/link_view.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class LinkPreview extends StatefulWidget {
|
||||
const LinkPreview({
|
||||
super.key,
|
||||
required this.link,
|
||||
required this.story,
|
||||
required this.onTap,
|
||||
required this.showMetadata,
|
||||
required this.showUrl,
|
||||
required this.isOfflineReading,
|
||||
@ -34,6 +34,7 @@ class LinkPreview extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Story story;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Web address (Url that need to be parsed)
|
||||
/// 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(
|
||||
double height, {
|
||||
String? title = '',
|
||||
@ -184,7 +172,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
description: desc ?? title ?? 'no comment yet.',
|
||||
imageUri: imageUri,
|
||||
imagePath: Constants.hackerNewsLogoPath,
|
||||
onTap: _launchURL,
|
||||
onTap: widget.onTap,
|
||||
titleTextStyle: widget.titleStyle,
|
||||
bodyTextOverflow: widget.bodyTextOverflow,
|
||||
bodyMaxLines: widget.bodyMaxLines,
|
||||
|
@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/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/utils/link_util.dart';
|
||||
|
||||
class LinkView extends StatelessWidget {
|
||||
LinkView({
|
||||
@ -41,7 +43,7 @@ class LinkView extends StatelessWidget {
|
||||
final String description;
|
||||
final String? imageUri;
|
||||
final String? imagePath;
|
||||
final void Function(String) onTap;
|
||||
final VoidCallback onTap;
|
||||
final TextStyle titleTextStyle;
|
||||
final bool showMultiMedia;
|
||||
final TextOverflow? bodyTextOverflow;
|
||||
@ -176,17 +178,26 @@ class LinkView extends StatelessWidget {
|
||||
titleStyle,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => onTap(url),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
if (showMultiMedia)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 8,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
),
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (showMultiMedia)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 8,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
),
|
||||
child: TapDownWrapper(
|
||||
onTap: () {
|
||||
if (url.isNotEmpty) {
|
||||
LinkUtil.launch(
|
||||
url,
|
||||
useHackiForHnLink: false,
|
||||
);
|
||||
} else {
|
||||
onTap();
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
height: layoutHeight,
|
||||
width: layoutHeight,
|
||||
@ -207,10 +218,13 @@ class LinkView extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: Dimens.pt5),
|
||||
SizedBox(
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: Dimens.pt5),
|
||||
TapDownWrapper(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: layoutHeight,
|
||||
width: layoutWidth - layoutHeight - 8,
|
||||
child: Column(
|
||||
@ -258,8 +272,8 @@ class LinkView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -37,37 +37,33 @@ class StoryTile extends StatelessWidget {
|
||||
return Semantics(
|
||||
label: story.screenReaderLabel,
|
||||
excludeSemantics: true,
|
||||
child: TapDownWrapper(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt12,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt12,
|
||||
),
|
||||
child: LinkPreview(
|
||||
story: story,
|
||||
link: story.url,
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
placeholderWidget: _LinkPreviewPlaceholder(
|
||||
height: height,
|
||||
),
|
||||
child: AbsorbPointer(
|
||||
child: LinkPreview(
|
||||
story: story,
|
||||
link: story.url,
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
placeholderWidget: _LinkPreviewPlaceholder(
|
||||
height: height,
|
||||
),
|
||||
errorImage: Constants.hackerNewsLogoLink,
|
||||
backgroundColor: Palette.transparent,
|
||||
borderRadius: Dimens.zero,
|
||||
removeElevation: true,
|
||||
bodyMaxLines: context.storyTileMaxLines,
|
||||
errorTitle: story.title,
|
||||
titleStyle: TextStyle(
|
||||
color: hasRead
|
||||
? Palette.grey[500]
|
||||
: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
showMetadata: showMetadata,
|
||||
showUrl: showUrl,
|
||||
),
|
||||
errorImage: Constants.hackerNewsLogoLink,
|
||||
backgroundColor: Palette.transparent,
|
||||
borderRadius: Dimens.zero,
|
||||
removeElevation: true,
|
||||
bodyMaxLines: context.storyTileMaxLines,
|
||||
errorTitle: story.title,
|
||||
titleStyle: TextStyle(
|
||||
color: hasRead
|
||||
? Palette.grey[500]
|
||||
: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
showMetadata: showMetadata,
|
||||
showUrl: showUrl,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
66
lib/utils/theme_util.dart
Normal file
66
lib/utils/theme_util.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -4,4 +4,5 @@ export 'link_util.dart';
|
||||
export 'linkifier_util.dart';
|
||||
export 'log_util.dart';
|
||||
export 'service_exception.dart';
|
||||
export 'theme_util.dart';
|
||||
export 'throttle.dart';
|
||||
|
@ -1,6 +1,6 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.4.0+104
|
||||
version: 1.4.3+107
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
Submodule submodules/flutter updated: 62bd79521d...4b12645012
Reference in New Issue
Block a user