├── .fvmrc ├── Gemfile ├── fastlane └── metadata │ └── android │ └── en-US │ ├── title.txt │ ├── changelogs │ ├── 22.txt │ ├── 38.txt │ ├── 35.txt │ ├── 36.txt │ ├── 23.txt │ ├── 21.txt │ ├── 34.txt │ ├── 24.txt │ ├── 42.txt │ ├── 45.txt │ ├── 32.txt │ ├── 49.txt │ ├── 25.txt │ ├── 29.txt │ ├── 30.txt │ ├── 33.txt │ ├── 46.txt │ ├── 27.txt │ ├── 28.txt │ ├── 40.txt │ ├── 26.txt │ ├── 43.txt │ ├── 39.txt │ ├── 44.txt │ ├── 48.txt │ ├── 37.txt │ ├── 41.txt │ ├── 47.txt │ └── 31.txt │ ├── short_description.txt │ ├── images │ ├── icon.png │ ├── featureGraphic.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ └── 5.png │ └── long_description.txt ├── glider.mockup ├── ios ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-50x50@1x.png │ │ │ ├── Icon-App-50x50@2x.png │ │ │ ├── Icon-App-57x57@1x.png │ │ │ ├── Icon-App-57x57@2x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-72x72@1x.png │ │ │ ├── Icon-App-72x72@2x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── .gitignore └── Podfile ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── assets ├── icons │ ├── icon.png │ ├── adaptive_icon.png │ ├── adaptive_icon.svg │ └── icon.svg └── fonts │ ├── NotoSans-Regular.ttf │ └── NotoSansMono-Regular.ttf ├── dart_dependency_validator.yaml ├── lib ├── stories_search │ ├── models │ │ ├── search_type.dart │ │ └── search_range.dart │ ├── bloc │ │ └── stories_search_event.dart │ └── view │ │ └── stories_search_view.dart ├── common │ ├── models │ │ └── status.dart │ ├── constants │ │ ├── app_uris.dart │ │ ├── app_animation.dart │ │ └── app_spacing.dart │ ├── transformers │ │ └── debounce.dart │ ├── extensions │ │ ├── bloc_base_extension.dart │ │ ├── widget_list_extension.dart │ │ ├── text_style_extension.dart │ │ ├── theme_data_extension.dart │ │ └── uri_extension.dart │ ├── mixins │ │ └── paginated_list_mixin.dart │ ├── interfaces │ │ └── menu_item.dart │ └── widgets │ │ ├── notification_canceler.dart │ │ ├── loading_widget.dart │ │ ├── loading_block.dart │ │ ├── text_select_dialog.dart │ │ ├── loading_text_block.dart │ │ ├── empty_widget.dart │ │ ├── confirm_dialog.dart │ │ ├── metadata_widget.dart │ │ ├── refreshable_scroll_view.dart │ │ ├── app_bar_progress_indicator.dart │ │ ├── preview_card.dart │ │ ├── animated_visibility.dart │ │ ├── failure_widget.dart │ │ └── preview_bottom_panel.dart ├── item │ ├── cubit │ │ └── item_cubit_event.dart │ ├── models │ │ ├── vote_type.dart │ │ ├── item_style.dart │ │ └── item_value.dart │ ├── typedefs │ │ └── item_typedefs.dart │ └── widgets │ │ ├── indented_widget.dart │ │ ├── item_bottom_sheet.dart │ │ └── avatar_widget.dart ├── user │ ├── cubit │ │ ├── user_cubit_event.dart │ │ └── user_state.dart │ ├── typedefs │ │ └── user_typedefs.dart │ ├── models │ │ ├── user_style.dart │ │ └── user_value.dart │ └── widgets │ │ └── user_bottom_sheet.dart ├── navigation_shell │ ├── cubit │ │ ├── navigation_shell_cubit_event.dart │ │ ├── navigation_shell_state.dart │ │ └── navigation_shell_cubit.dart │ └── models │ │ └── navigation_shell_action.dart ├── app │ ├── extensions │ │ ├── string_extension.dart │ │ ├── text_scaler_extension.dart │ │ ├── theme_mode_extension.dart │ │ ├── dynamic_scheme_extension.dart │ │ └── super_sliver_list_extension.dart │ ├── bootstrap │ │ ├── app_bloc_observer.dart │ │ └── bootstrap.dart │ └── models │ │ ├── app_route.dart │ │ └── dialog_page.dart ├── settings │ ├── cubit │ │ └── settings_cubit_event.dart │ ├── extensions │ │ ├── theme_mode_extension.dart │ │ └── variant_extension.dart │ ├── widgets │ │ └── menu_list_tile.dart │ └── view │ │ └── theme_color_dialog.dart ├── l10n │ └── extensions │ │ └── app_localizations_extension.dart ├── main.dart ├── user_item_search │ └── bloc │ │ ├── user_item_search_event.dart │ │ ├── user_item_search_state.dart │ │ └── user_item_search_bloc.dart ├── story_item_search │ └── bloc │ │ ├── story_item_search_event.dart │ │ ├── story_item_search_state.dart │ │ └── story_item_search_bloc.dart ├── auth │ └── cubit │ │ ├── auth_state.dart │ │ └── auth_cubit.dart ├── edit │ ├── models │ │ ├── text_input.dart │ │ └── title_input.dart │ └── cubit │ │ └── edit_state.dart ├── reply │ ├── models │ │ └── text_input.dart │ └── cubit │ │ ├── reply_state.dart │ │ └── reply_cubit.dart ├── submit │ ├── models │ │ ├── text_input.dart │ │ ├── title_input.dart │ │ └── url_input.dart │ └── cubit │ │ └── submit_state.dart ├── favorites │ └── cubit │ │ ├── favorites_state.dart │ │ └── favorites_cubit.dart ├── inbox │ └── cubit │ │ ├── inbox_state.dart │ │ └── inbox_cubit.dart ├── stories │ ├── models │ │ └── story_type.dart │ ├── cubit │ │ └── stories_state.dart │ └── view │ │ └── stories_type_view.dart ├── story_similar │ └── cubit │ │ ├── story_similar_state.dart │ │ └── story_similar_cubit.dart └── whats_new │ └── view │ └── whats_new_page.dart ├── android ├── gradle.properties ├── app │ ├── proguard-rules.pro │ ├── src │ │ └── main │ │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── drawable-hdpi │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-mdpi │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ └── styles.xml │ │ │ ├── drawable-xhdpi │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxhdpi │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxxhdpi │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── xml │ │ │ │ └── backup_rules.xml │ │ │ ├── values-v29 │ │ │ │ └── styles.xml │ │ │ ├── values-night-v29 │ │ │ │ └── styles.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ └── ic_launcher.xml │ │ │ ├── drawable │ │ │ │ └── launch_background.xml │ │ │ ├── drawable-v21 │ │ │ │ └── launch_background.xml │ │ │ └── values-night │ │ │ │ └── styles.xml │ │ │ └── kotlin │ │ │ └── nl │ │ │ └── viter │ │ │ └── glider │ │ │ └── MainActivity.kt │ └── build.gradle ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── build.gradle └── settings.gradle ├── macos ├── Runner │ ├── Configs │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ ├── Warnings.xcconfig │ │ └── AppInfo.xcconfig │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ ├── app_icon_16.png │ │ │ ├── app_icon_32.png │ │ │ ├── app_icon_64.png │ │ │ ├── app_icon_1024.png │ │ │ ├── app_icon_128.png │ │ │ ├── app_icon_256.png │ │ │ ├── app_icon_512.png │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Release.entitlements │ ├── MainFlutterWindow.swift │ ├── DebugProfile.entitlements │ └── Info.plist ├── .gitignore ├── Flutter │ ├── Flutter-Debug.xcconfig │ ├── Flutter-Release.xcconfig │ └── GeneratedPluginRegistrant.swift ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Runner.xcodeproj │ └── project.xcworkspace │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── Podfile ├── packages ├── glider_domain │ ├── lib │ │ ├── src │ │ │ ├── entities │ │ │ │ ├── theme_mode.dart │ │ │ │ ├── item_descendant.dart │ │ │ │ └── user.dart │ │ │ ├── website_repository.dart │ │ │ ├── extensions │ │ │ │ ├── behavior_subject_extension.dart │ │ │ │ ├── behavior_subject_map_extension.dart │ │ │ │ └── string_extension.dart │ │ │ ├── package_repository.dart │ │ │ ├── auth_repository.dart │ │ │ └── user_repository.dart │ │ └── glider_domain.dart │ ├── analysis_options.yaml │ ├── pubspec.yaml │ └── .gitignore └── glider_data │ ├── analysis_options.yaml │ ├── lib │ ├── glider_data.dart │ └── src │ │ ├── generic_website_service.dart │ │ ├── secure_storage_service.dart │ │ ├── dtos │ │ ├── user_dto.dart │ │ ├── item_dto.dart │ │ └── algolia_search_dto.dart │ │ └── hacker_news_api_service.dart │ ├── pubspec.yaml │ └── .gitignore ├── l10n.yaml ├── .github ├── dependabot.yaml ├── workflows │ └── ci.yml └── actions │ └── bootstrap │ └── action.yml ├── analysis_options.yaml ├── flutter_launcher_icons.yaml ├── melos.yaml ├── LICENSE ├── README.md ├── .metadata ├── pubspec.yaml └── .gitignore /.fvmrc: -------------------------------------------------------------------------------- 1 | { 2 | "flutter": "3.22.0-0.1.pre" 3 | } -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Glider for Hacker News -------------------------------------------------------------------------------- /glider.mockup: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/glider.mockup -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dart.flutterSdkPath": ".fvm/versions/3.22.0-0.1.pre" 3 | } -------------------------------------------------------------------------------- /assets/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/assets/icons/icon.png -------------------------------------------------------------------------------- /dart_dependency_validator.yaml: -------------------------------------------------------------------------------- 1 | exclude: 2 | - packages/** 3 | 4 | ignore: 5 | - flutter_gen 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/22.txt: -------------------------------------------------------------------------------- 1 | • Fixed incorrect URL validation on submitting a post -------------------------------------------------------------------------------- /lib/stories_search/models/search_type.dart: -------------------------------------------------------------------------------- 1 | enum SearchType { 2 | search, 3 | catchUp; 4 | } 5 | -------------------------------------------------------------------------------- /assets/icons/adaptive_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/assets/icons/adaptive_icon.png -------------------------------------------------------------------------------- /assets/fonts/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/assets/fonts/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /lib/common/models/status.dart: -------------------------------------------------------------------------------- 1 | enum Status { 2 | initial, 3 | loading, 4 | success, 5 | failure, 6 | } 7 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -dontwarn javax.annotation.Nullable 2 | -dontwarn javax.annotation.concurrent.GuardedBy 3 | -------------------------------------------------------------------------------- /assets/fonts/NotoSansMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/assets/fonts/NotoSansMono-Regular.ttf -------------------------------------------------------------------------------- /macos/Runner/Configs/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Debug.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/entities/theme_mode.dart: -------------------------------------------------------------------------------- 1 | enum ThemeMode { 2 | system, 3 | light, 4 | dark, 5 | } 6 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Release.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Glider is an opinionated Hacker News client. Ad-free, open-source, no-nonsense. -------------------------------------------------------------------------------- /macos/.gitignore: -------------------------------------------------------------------------------- 1 | # Flutter-related 2 | **/Flutter/ephemeral/ 3 | **/Pods/ 4 | 5 | # Xcode-related 6 | **/dgph 7 | **/xcuserdata/ 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/38.txt: -------------------------------------------------------------------------------- 1 | • Fixed jagged text rendering on some devices 2 | • Fixed walkthrough not working as intended -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/l10n/arb 2 | template-arb-file: app_en.arb 3 | output-localization-file: app_localizations.dart 4 | nullable-getter: false 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/35.txt: -------------------------------------------------------------------------------- 1 | • Implemented avatars matching "HN Avatars in 357 bytes" 2 | • Added menu option for selecting a post's text -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/36.txt: -------------------------------------------------------------------------------- 1 | • Implemented avatars matching "HN Avatars in 357 bytes" 2 | • Added menu option for selecting a post's text -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /macos/Flutter/Flutter-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Flutter/Flutter-Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #ffa726 4 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/23.txt: -------------------------------------------------------------------------------- 1 | • Added caching to allow reading of previously opened threads while offline 2 | • Fixed incorrect navigation bar color on older Android versions -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mosc/Glider/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /android/app/src/main/kotlin/nl/viter/glider/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package nl.viter.glider 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/21.txt: -------------------------------------------------------------------------------- 1 | • Sorted favorites from new to old 2 | • Tweaked look of story usernames 3 | • Unescaped HTML in story titles 4 | • Fixed collapsing behaviour regression -------------------------------------------------------------------------------- /packages/glider_data/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:leancode_lint/analysis_options.yaml 2 | 3 | analyzer: 4 | errors: 5 | included_file_warning: ignore 6 | plugins: 7 | custom_lint 8 | -------------------------------------------------------------------------------- /packages/glider_domain/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:leancode_lint/analysis_options.yaml 2 | 3 | analyzer: 4 | errors: 5 | included_file_warning: ignore 6 | plugins: 7 | custom_lint 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/34.txt: -------------------------------------------------------------------------------- 1 | • Added a text size setting 2 | • Implemented pagination as an alternative to infinite scroll 3 | • Allowed hiding of job posts 4 | • Improved performance a bit -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/24.txt: -------------------------------------------------------------------------------- 1 | • Removed offline notification because it would occasionally appear while online 2 | • Fixed formatting of copied stories and comments 3 | • Allowed links to be copied -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/42.txt: -------------------------------------------------------------------------------- 1 | - Added action buttons setting to upvote and favorite without long-pressing 2 | - Fixed inbox page for users with a large number of replies 3 | - Made various minor tweaks -------------------------------------------------------------------------------- /lib/common/constants/app_uris.dart: -------------------------------------------------------------------------------- 1 | abstract final class AppUris { 2 | static final hackerNewsUri = Uri.https('news.ycombinator.com'); 3 | 4 | static final projectUri = Uri.https('github.com', 'Mosc/Glider'); 5 | } 6 | -------------------------------------------------------------------------------- /lib/item/cubit/item_cubit_event.dart: -------------------------------------------------------------------------------- 1 | part of 'item_cubit.dart'; 2 | 3 | sealed class ItemCubitEvent {} 4 | 5 | final class ItemActionFailedEvent implements ItemCubitEvent { 6 | const ItemActionFailedEvent(); 7 | } 8 | -------------------------------------------------------------------------------- /lib/user/cubit/user_cubit_event.dart: -------------------------------------------------------------------------------- 1 | part of 'user_cubit.dart'; 2 | 3 | sealed class UserCubitEvent {} 4 | 5 | final class UserActionFailedEvent implements UserCubitEvent { 6 | const UserActionFailedEvent(); 7 | } 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/navigation_shell/cubit/navigation_shell_cubit_event.dart: -------------------------------------------------------------------------------- 1 | part of 'navigation_shell_cubit.dart'; 2 | 3 | sealed class NavigationShellCubitEvent {} 4 | 5 | final class ShowWhatsNewEvent implements NavigationShellCubitEvent {} 6 | -------------------------------------------------------------------------------- /lib/user/typedefs/user_typedefs.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:glider_domain/glider_domain.dart'; 3 | 4 | typedef UserCallback = void Function( 5 | BuildContext context, 6 | User item, 7 | ); 8 | -------------------------------------------------------------------------------- /lib/app/extensions/string_extension.dart: -------------------------------------------------------------------------------- 1 | extension StringExtension on String { 2 | bool containsWord(String other, {bool caseSensitive = true}) => 3 | RegExp('\\b$other\\b', caseSensitive: caseSensitive).hasMatch(this); 4 | } 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/45.txt: -------------------------------------------------------------------------------- 1 | - Added action to mark items as read or unread 2 | - Added setting to clear all read statuses 3 | - Fixed login autofill issues by replacing login flow 4 | - Improved (scrolling) performance -------------------------------------------------------------------------------- /lib/settings/cubit/settings_cubit_event.dart: -------------------------------------------------------------------------------- 1 | part of 'settings_cubit.dart'; 2 | 3 | sealed class SettingsCubitEvent {} 4 | 5 | final class SettingsActionFailedEvent implements SettingsCubitEvent { 6 | const SettingsActionFailedEvent(); 7 | } 8 | -------------------------------------------------------------------------------- /lib/navigation_shell/cubit/navigation_shell_state.dart: -------------------------------------------------------------------------------- 1 | part of 'navigation_shell_cubit.dart'; 2 | 3 | class NavigationShellState with EquatableMixin { 4 | const NavigationShellState(); 5 | 6 | @override 7 | List get props => []; 8 | } 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/32.txt: -------------------------------------------------------------------------------- 1 | • Fixed favicons by hosting own favicon service 2 | • Added setting to disable gestures to work around conflicts with system navigation gestures 3 | • Replaced long-press bottom sheet with bar directly below list tile -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/49.txt: -------------------------------------------------------------------------------- 1 | - Added setting to change navigation bar or rail into navigation drawer 2 | - Improved pure background setting to apply to more UI elemeents 3 | - Changed padding between story title and metadata to be a bit tighter -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/25.txt: -------------------------------------------------------------------------------- 1 | • Added customization of main stories listing under Appearance menu option 2 | • Made application fully localizable (contribute your translations on GitHub!) 3 | • Fixed synchronization issues 4 | • Made lots of minor tweaks -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "pub" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /lib/item/models/vote_type.dart: -------------------------------------------------------------------------------- 1 | enum VoteType { 2 | upvote, 3 | downvote; 4 | } 5 | 6 | extension NullableVoteTypeExtension on VoteType? { 7 | bool get upvoted => this == VoteType.upvote; 8 | 9 | bool get downvoted => this == VoteType.downvote; 10 | } 11 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/29.txt: -------------------------------------------------------------------------------- 1 | • Replaced (broken) thumbnails with favicons 2 | • Fixed share menu option copying instead of sharing 3 | • Made space and black themes more consistent in color usage 4 | • Fixed collapsing comment trees not working for some users -------------------------------------------------------------------------------- /lib/common/transformers/debounce.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_bloc/flutter_bloc.dart'; 2 | import 'package:rxdart/transformers.dart'; 3 | 4 | EventTransformer debounce(Duration duration) => 5 | (events, mapper) => events.debounceTime(duration).switchMap(mapper); 6 | -------------------------------------------------------------------------------- /lib/settings/extensions/theme_mode_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:glider_domain/glider_domain.dart'; 2 | import 'package:intl/intl.dart' as intl; 3 | 4 | extension ThemeModeExtension on ThemeMode { 5 | String get capitalizedLabel => intl.toBeginningOfSentenceCase(name)!; 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dart-code.dart-code", 6 | "dart-code.flutter", 7 | "felixangelov.bloc" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /lib/l10n/extensions/app_localizations_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | extension AppLocalizationsExtension on BuildContext { 5 | AppLocalizations get l10n => AppLocalizations.of(this); 6 | } 7 | -------------------------------------------------------------------------------- /lib/settings/extensions/variant_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:intl/intl.dart' as intl; 2 | import 'package:material_color_utilities/scheme/variant.dart'; 3 | 4 | extension VariantExtension on Variant { 5 | String get capitalizedLabel => intl.toBeginningOfSentenceCase(label)!; 6 | } 7 | -------------------------------------------------------------------------------- /macos/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | @NSApplicationMain 5 | class AppDelegate: FlutterAppDelegate { 6 | override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 7 | return true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/common/extensions/bloc_base_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | 3 | extension BlocBaseExtension on BlocBase { 4 | void safeEmit(T state) { 5 | // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member 6 | if (!isClosed) emit(state); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /assets/icons/adaptive_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/30.txt: -------------------------------------------------------------------------------- 1 | • Added search functionality in comments 2 | • Optimized network usage, especially for search 3 | • Animated any list changes, such as during load and refresh 4 | • Allowed selecting system theme with specific dark mode color — this change resets your currently selected theme -------------------------------------------------------------------------------- /macos/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:leancode_lint/analysis_options.yaml 2 | 3 | analyzer: 4 | errors: 5 | included_file_warning: ignore 6 | plugins: 7 | custom_lint 8 | 9 | linter: 10 | rules: 11 | always_put_control_body_on_new_line: false 12 | avoid_positional_boolean_parameters: false 13 | -------------------------------------------------------------------------------- /flutter_launcher_icons.yaml: -------------------------------------------------------------------------------- 1 | # flutter pub run flutter_launcher_icons 2 | flutter_launcher_icons: 3 | image_path: "assets/icons/icon.png" 4 | android: true 5 | adaptive_icon_background: "#ffa726" 6 | adaptive_icon_foreground: "assets/icons/adaptive_icon.png" 7 | ios: true 8 | macos: 9 | generate: true 10 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/33.txt: -------------------------------------------------------------------------------- 1 | • Added favorites search option 2 | • Allowed blocking of users 3 | • Moved speed dial functionality to app bar menu 4 | • Improved performance of opening large discussions 5 | • Increased distance between lines of text 6 | • Made various styling tweaks, such as making the status bar match the app bar -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/46.txt: -------------------------------------------------------------------------------- 1 | - Added setting to enable custom tabs 2 | - Fixed only first opened deeplink loading correctly 3 | - Fixed edit/delete actions not properly authenticating and thus failing 4 | - Fixed comments page sometimes staying in loading state when error occurs 5 | - Improved search and catch up page load speed -------------------------------------------------------------------------------- /macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/icons/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /lib/user/models/user_style.dart: -------------------------------------------------------------------------------- 1 | enum UserStyle { 2 | full(showPrimary: true, showSecondary: true), 3 | primary(showPrimary: true), 4 | secondary(showSecondary: true); 5 | 6 | const UserStyle({ 7 | this.showPrimary = false, 8 | this.showSecondary = false, 9 | }); 10 | 11 | final bool showPrimary; 12 | final bool showSecondary; 13 | } 14 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/27.txt: -------------------------------------------------------------------------------- 1 | • Added swipe to go back on edge of pages 2 | • Duplicated slide actions (such as voting) in the long-press menu and action bar overflow menu for improved discoverability 3 | • Signaled preference for high refresh rates to Android OS 4 | • Set navigation bar to translucent on more Android versions 5 | • Made various minor tweaks -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/28.txt: -------------------------------------------------------------------------------- 1 | • Added swipe to go back on edge of pages 2 | • Duplicated slide actions (such as voting) in the long-press menu and action bar overflow menu for improved discoverability 3 | • Signaled preference for high refresh rates to Android OS 4 | • Set navigation bar to translucent on more Android versions 5 | • Made various minor tweaks -------------------------------------------------------------------------------- /macos/Runner/Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/40.txt: -------------------------------------------------------------------------------- 1 | • Added long-press option to show parent story or comment 2 | • Added tags next to usernames for noteworthy YC employees 3 | • Added monochrome icon as part of adaptive icon 4 | • Added licenses page in settings 5 | • Improved reliability of story comment tree loading 6 | • Fixed scroll-to-top button behavior to only show on scrolling up -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:glider/app/bootstrap/bootstrap.dart'; 4 | import 'package:glider/app/view/app.dart'; 5 | 6 | Future main() async => bootstrap( 7 | (appContainer, appRouter, deviceInfo) => App( 8 | appContainer.settingsCubit, 9 | appRouter.config, 10 | deviceInfo, 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-v29/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/website_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:glider_data/glider_data.dart'; 2 | 3 | class WebsiteRepository { 4 | const WebsiteRepository(this._genericWebsiteService); 5 | 6 | final GenericWebsiteService _genericWebsiteService; 7 | 8 | Future getWebsiteTitle(Uri url) async => 9 | _genericWebsiteService.getTitle(url); 10 | } 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night-v29/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/26.txt: -------------------------------------------------------------------------------- 1 | • Added swipe to go back on edge of pages 2 | • Duplicated slide actions (such as voting) in the long-press menu and action bar overflow menu for improved discoverability 3 | • Signaled preference for high refresh rates to Android OS 4 | • Set navigation bar to translucent on Android 10+ and black on lower versions 5 | • Made various minor tweaks -------------------------------------------------------------------------------- /lib/common/mixins/paginated_list_mixin.dart: -------------------------------------------------------------------------------- 1 | import 'package:glider/common/mixins/data_mixin.dart'; 2 | 3 | mixin PaginatedListMixin on DataMixin> { 4 | static const pageSize = 30; 5 | 6 | int get page; 7 | 8 | Iterable? get loadedData => data?.take(page * pageSize); 9 | 10 | Iterable? get currentPageData => loadedData?.skip((page - 1) * pageSize); 11 | } 12 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /lib/app/extensions/text_scaler_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/rendering.dart'; 2 | 3 | extension TextScalerExtension on TextScaler { 4 | double getFontSizeMultiplier({ 5 | required double? fontSize, 6 | required double fallbackFontSize, 7 | }) { 8 | final defaultFontSize = fontSize ?? fallbackFontSize; 9 | return scale(defaultFontSize) / fallbackFontSize; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/43.txt: -------------------------------------------------------------------------------- 1 | - Added setting to enable downvoting 2 | - Added theme mode setting to force light or dark theme 3 | - Moved favicon in thread view down to provide more space for title 4 | - Changed Android versions below 12 to show glowing overscroll indicator 5 | - Fixed system navigation bar color on Android versions below 10 6 | - Fixed themed icons rendering blank at some densities -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = '../build' 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | project.evaluationDependsOn(':app') 14 | } 15 | 16 | tasks.register("clean", Delete) { 17 | delete rootProject.buildDir 18 | } 19 | -------------------------------------------------------------------------------- /lib/common/extensions/widget_list_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | extension WidgetListExtension on List { 4 | List spaced({double? width, double? height}) => [ 5 | ...expand( 6 | (widget) sync* { 7 | yield SizedBox(width: width, height: height); 8 | yield widget; 9 | }, 10 | ).skip(1), 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/39.txt: -------------------------------------------------------------------------------- 1 | • Added long-press option to show parent story or comment 2 | • Added tags next to usernames for noteworthy YC employees 3 | • Added monochrome icon as part of adaptive icon 4 | • Added licenses page in settings 5 | • Changed behavior of verbatim blocks to wrap 6 | • Improved reliability of story comment tree loading 7 | • Fixed scroll-to-top button behavior to only show on scrolling up -------------------------------------------------------------------------------- /lib/common/interfaces/menu_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:glider/auth/cubit/auth_cubit.dart'; 3 | import 'package:glider/settings/cubit/settings_cubit.dart'; 4 | 5 | abstract interface class MenuItem { 6 | bool isVisible(S state, AuthState authState, SettingsState settingsState); 7 | 8 | IconData? icon(S state); 9 | 10 | String label(BuildContext context, S state); 11 | } 12 | -------------------------------------------------------------------------------- /lib/item/typedefs/item_typedefs.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:glider/item/cubit/item_cubit.dart'; 3 | import 'package:glider_domain/glider_domain.dart'; 4 | 5 | typedef ItemCallback = void Function( 6 | BuildContext context, 7 | Item item, 8 | ); 9 | 10 | typedef ItemWithCubitCallback = void Function( 11 | BuildContext context, 12 | Item item, 13 | ItemCubit itemCubit, 14 | ); 15 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/extensions/behavior_subject_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:rxdart/subjects.dart'; 2 | 3 | extension BehaviorSubjectExtension on BehaviorSubject { 4 | Future addAsync({required Future Function() asyncValue}) async { 5 | try { 6 | final value = await asyncValue(); 7 | add(value); 8 | } on Object catch (e, st) { 9 | addError(e, st); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/44.txt: -------------------------------------------------------------------------------- 1 | - Added font setting 2 | - Added setting to disable favicons 3 | - Added setting to disable user avatars 4 | - Changed font used for code to be smaller 5 | - Fixed gray screen appearing upon opening deeplinks 6 | - Fixed custom date range picker opening more than once 7 | - Added bottom padding to pages with floating action buttons 8 | - Improved error handling slightly by showing a snackbar on errors -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/48.txt: -------------------------------------------------------------------------------- 1 | - Improved filter setting to match on word boundaries 2 | - Improved pure background setting to also darken the app bar and navigation bar 3 | - Fixed Hacker News links opened within the app clearing navigation stack 4 | - Fixed show jobs setting not persisting 5 | - Fixed user page showing a nondescript error shortly after registering new account 6 | - Fixed incorrect story title suffix detection -------------------------------------------------------------------------------- /packages/glider_data/lib/glider_data.dart: -------------------------------------------------------------------------------- 1 | export 'src/algolia_api_service.dart'; 2 | export 'src/dtos/algolia_search_dto.dart'; 3 | export 'src/dtos/item_dto.dart'; 4 | export 'src/dtos/user_dto.dart'; 5 | export 'src/generic_website_service.dart'; 6 | export 'src/hacker_news_api_service.dart'; 7 | export 'src/hacker_news_website_service.dart'; 8 | export 'src/secure_storage_service.dart'; 9 | export 'src/shared_preferences_service.dart'; 10 | -------------------------------------------------------------------------------- /packages/glider_data/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: glider_data 2 | version: 0.1.0+1 3 | publish_to: none 4 | 5 | environment: 6 | sdk: ">=3.3.0-0 <4.0.0" 7 | 8 | dependencies: 9 | collection: any 10 | compute: ^1.0.2 11 | flutter_secure_storage: ^9.0.0 12 | html: ^0.15.4 13 | http: ^1.2.1 14 | shared_preferences: ^2.2.2 15 | 16 | dev_dependencies: 17 | custom_lint: ^0.6.4 18 | dependency_validator: ^3.2.3 19 | leancode_lint: ^12.0.0 20 | -------------------------------------------------------------------------------- /lib/common/extensions/text_style_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | extension TextStyleExtension on TextStyle { 4 | double? scaledFontSize(BuildContext context) => fontSize != null 5 | ? MediaQuery.textScalerOf(context).scale(fontSize!) 6 | : null; 7 | 8 | double? leading(BuildContext context) => fontSize != null && height != null 9 | ? scaledFontSize(context)! * (height! - 1) 10 | : null; 11 | } 12 | -------------------------------------------------------------------------------- /lib/app/extensions/theme_mode_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart' as material; 2 | import 'package:glider_domain/glider_domain.dart'; 3 | 4 | extension ThemeModeExtension on ThemeMode { 5 | material.ThemeMode toMaterialThemeMode() => switch (this) { 6 | ThemeMode.system => material.ThemeMode.system, 7 | ThemeMode.light => material.ThemeMode.light, 8 | ThemeMode.dark => material.ThemeMode.dark, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /lib/common/widgets/notification_canceler.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class NotificationCanceler extends StatelessWidget { 4 | const NotificationCanceler({super.key, required this.child}); 5 | 6 | final Widget child; 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return NotificationListener( 11 | onNotification: (notification) => true, 12 | child: child, 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/item/models/item_style.dart: -------------------------------------------------------------------------------- 1 | enum ItemStyle { 2 | full(showPrimary: true, showSecondary: true), 3 | overview(showPrimary: true, showUrlHost: true), 4 | primary(showPrimary: true), 5 | secondary(showSecondary: true); 6 | 7 | const ItemStyle({ 8 | this.showPrimary = false, 9 | this.showSecondary = false, 10 | this.showUrlHost = false, 11 | }); 12 | 13 | final bool showPrimary; 14 | final bool showSecondary; 15 | final bool showUrlHost; 16 | } 17 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/37.txt: -------------------------------------------------------------------------------- 1 | • Added Inbox page with replies to logged in user's posts 2 | • Made collapsed items show number of total descendants rather than just children 3 | • Added floating action button for scrolling to top of page 4 | • Moved Appearance and Settings bottom sheets into Settings page 5 | • Enabled avatars by default 6 | • Fixed post submission always reporting an error, even when successful 7 | • Fixed username and password fields not supporting autofill -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/41.txt: -------------------------------------------------------------------------------- 1 | - Rewrote the app internals 2 | - Revamped the UI based on Material 3 3 | - Resolved numerous issues 4 | - Added top level comment navigation (#19) 5 | - Removed gestures, due to general bugginess (#36) 6 | - Fixed auth issues by fetching cookie from webview (#63, #91) 7 | - Added support for favorites exporting (#75) 8 | - Fixed thread loading glitches (#84, #86) 9 | - Fixed incorrect detection of nested quote blocks (#85) -------------------------------------------------------------------------------- /macos/Runner/MainFlutterWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | class MainFlutterWindow: NSWindow { 5 | override func awakeFromNib() { 6 | let flutterViewController = FlutterViewController.init() 7 | let windowFrame = self.frame 8 | self.contentViewController = flutterViewController 9 | self.setFrame(windowFrame, display: true) 10 | 11 | RegisterGeneratedPlugins(registry: flutterViewController) 12 | 13 | super.awakeFromNib() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /macos/Runner/DebugProfile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.network.server 10 | 11 | com.apple.security.network.client 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/47.txt: -------------------------------------------------------------------------------- 1 | - Added setting to configure filters for words and domains 2 | - Added animation to list tiles changing size, most noticably when comments load in 3 | - Changed what's new page to not show on new installs 4 | - Changed show job stories setting to also hide jobs tab when disabled 5 | - Changed navigation rail on large devices or orientations to be more compact 6 | - Removed persistence of search queries across sessions 7 | - Fixed custom tabs setting not persisting -------------------------------------------------------------------------------- /lib/app/extensions/dynamic_scheme_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:material_color_utilities/material_color_utilities.dart'; 2 | 3 | extension DynamicSchemeExtension on DynamicScheme { 4 | CorePalette toColorPalette() => CorePalette.fromList( 5 | [ 6 | ...primaryPalette.asList, 7 | ...secondaryPalette.asList, 8 | ...tertiaryPalette.asList, 9 | ...neutralPalette.asList, 10 | ...neutralVariantPalette.asList, 11 | ], 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /lib/common/widgets/loading_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_spacing.dart'; 3 | 4 | class LoadingWidget extends StatelessWidget { 5 | const LoadingWidget({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return const Padding( 10 | padding: AppSpacing.defaultTilePadding, 11 | child: Center( 12 | child: CircularProgressIndicator.adaptive(), 13 | ), 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/31.txt: -------------------------------------------------------------------------------- 1 | • Implemented edit and delete functionality (within 2 hours) 2 | • Retry failed network calls 3 | • Added setting to disable custom tabs usage 4 | • Fixed indentation of code in comments 5 | 6 | Previously: 7 | • Added search functionality in comments 8 | • Optimized network usage, especially for search 9 | • Animated any list changes, such as during load and refresh 10 | • Allowed selecting system theme with specific dark mode color — this change resets your currently selected theme -------------------------------------------------------------------------------- /packages/glider_domain/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: glider_domain 2 | version: 0.1.0+1 3 | publish_to: none 4 | 5 | environment: 6 | sdk: ">=3.3.0-0 <4.0.0" 7 | 8 | dependencies: 9 | compute: ^1.0.2 10 | equatable: ^2.0.5 11 | glider_data: 12 | path: ../glider_data 13 | html: ^0.15.4 14 | material_color_utilities: any 15 | package_info_plus: ^7.0.0 16 | pub_semver: ^2.1.4 17 | rxdart: ^0.27.7 18 | 19 | dev_dependencies: 20 | custom_lint: ^0.6.4 21 | dependency_validator: ^3.2.3 22 | leancode_lint: ^12.0.0 23 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/glider_domain.dart: -------------------------------------------------------------------------------- 1 | export 'src/auth_repository.dart'; 2 | export 'src/entities/item.dart'; 3 | export 'src/entities/item_descendant.dart'; 4 | export 'src/entities/theme_mode.dart'; 5 | export 'src/entities/user.dart'; 6 | export 'src/item_interaction_repository.dart'; 7 | export 'src/item_repository.dart'; 8 | export 'src/package_repository.dart'; 9 | export 'src/settings_repository.dart'; 10 | export 'src/user_interaction_repository.dart'; 11 | export 'src/user_repository.dart'; 12 | export 'src/website_repository.dart'; 13 | -------------------------------------------------------------------------------- /packages/glider_data/lib/src/generic_website_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:compute/compute.dart'; 2 | import 'package:html/parser.dart' as html_parser; 3 | import 'package:http/http.dart' as http; 4 | 5 | class GenericWebsiteService { 6 | const GenericWebsiteService(this._client); 7 | 8 | final http.Client _client; 9 | 10 | Future getTitle(Uri url) async { 11 | final response = await _client.get(url); 12 | return compute( 13 | (body) => html_parser.parse(body).querySelector('title')?.text, 14 | response.body, 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/user_item_search/bloc/user_item_search_event.dart: -------------------------------------------------------------------------------- 1 | part of 'user_item_search_bloc.dart'; 2 | 3 | sealed class UserItemSearchEvent with EquatableMixin { 4 | const UserItemSearchEvent(); 5 | } 6 | 7 | final class LoadUserItemSearchEvent extends UserItemSearchEvent { 8 | const LoadUserItemSearchEvent(); 9 | 10 | @override 11 | List get props => []; 12 | } 13 | 14 | final class SetTextUserItemSearchEvent extends UserItemSearchEvent { 15 | const SetTextUserItemSearchEvent(this.text); 16 | 17 | final String? text; 18 | 19 | @override 20 | List get props => [text]; 21 | } 22 | -------------------------------------------------------------------------------- /lib/app/bootstrap/app_bloc_observer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:bloc/bloc.dart'; 4 | 5 | class AppBlocObserver extends BlocObserver { 6 | const AppBlocObserver(); 7 | 8 | @override 9 | void onChange(BlocBase bloc, Change change) { 10 | log('onChange(${bloc.runtimeType}, $change)'); 11 | super.onChange(bloc, change); 12 | } 13 | 14 | @override 15 | void onError(BlocBase bloc, Object error, StackTrace stackTrace) { 16 | super.onError(bloc, error, stackTrace); 17 | log('onError(${bloc.runtimeType}, $error, $stackTrace)'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/story_item_search/bloc/story_item_search_event.dart: -------------------------------------------------------------------------------- 1 | part of 'story_item_search_bloc.dart'; 2 | 3 | sealed class StoryItemSearchEvent with EquatableMixin { 4 | const StoryItemSearchEvent(); 5 | } 6 | 7 | final class LoadStoryItemSearchEvent extends StoryItemSearchEvent { 8 | const LoadStoryItemSearchEvent(); 9 | 10 | @override 11 | List get props => []; 12 | } 13 | 14 | final class SetTextStoryItemSearchEvent extends StoryItemSearchEvent { 15 | const SetTextStoryItemSearchEvent(this.text); 16 | 17 | final String? text; 18 | 19 | @override 20 | List get props => [text]; 21 | } 22 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/extensions/behavior_subject_map_extension.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:glider_domain/src/extensions/behavior_subject_extension.dart'; 4 | import 'package:rxdart/subjects.dart'; 5 | 6 | extension BehaviorSubjectMapExtension on Map> { 7 | BehaviorSubject getOrAdd(K key, {Future Function()? asyncSeed}) { 8 | if (!containsKey(key)) { 9 | this[key] = BehaviorSubject(); 10 | 11 | if (asyncSeed != null) { 12 | unawaited(this[key]!.addAsync(asyncValue: asyncSeed)); 13 | } 14 | } 15 | 16 | return this[key]!; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings 2 | GCC_WARN_UNDECLARED_SELECTOR = YES 3 | CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES 4 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 5 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 6 | CLANG_WARN_PRAGMA_PACK = YES 7 | CLANG_WARN_STRICT_PROTOTYPES = YES 8 | CLANG_WARN_COMMA = YES 9 | GCC_WARN_STRICT_SELECTOR_MATCH = YES 10 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES 11 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 12 | GCC_WARN_SHADOW = YES 13 | CLANG_WARN_UNREACHABLE_CODE = YES 14 | -------------------------------------------------------------------------------- /lib/common/widgets/loading_block.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class LoadingBlock extends StatelessWidget { 4 | const LoadingBlock({super.key, this.width, this.height}); 5 | 6 | final double? width; 7 | final double? height; 8 | 9 | static const double opacity = 0.25; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Container( 14 | width: width, 15 | height: height, 16 | decoration: BoxDecoration( 17 | borderRadius: const BorderRadius.all(Radius.circular(4)), 18 | color: Theme.of(context).colorScheme.outline.withOpacity(opacity), 19 | ), 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /macos/Runner/Configs/AppInfo.xcconfig: -------------------------------------------------------------------------------- 1 | // Application-level settings for the Runner target. 2 | // 3 | // This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the 4 | // future. If not, the values below would default to using the project name when this becomes a 5 | // 'flutter create' template. 6 | 7 | // The application's name. By default this is also the title of the Flutter window. 8 | PRODUCT_NAME = Glider 9 | 10 | // The application's bundle identifier 11 | PRODUCT_BUNDLE_IDENTIFIER = nl.viter.glider 12 | 13 | // The copyright displayed in application information 14 | PRODUCT_COPYRIGHT = Copyright © 2023 nl.viter. All rights reserved. 15 | -------------------------------------------------------------------------------- /lib/auth/cubit/auth_state.dart: -------------------------------------------------------------------------------- 1 | part of 'auth_cubit.dart'; 2 | 3 | class AuthState with EquatableMixin { 4 | const AuthState({ 5 | this.isLoggedIn = false, 6 | this.username, 7 | }); 8 | 9 | final bool isLoggedIn; 10 | final String? username; 11 | 12 | AuthState copyWith({ 13 | bool Function()? isLoggedIn, 14 | String? Function()? username, 15 | }) => 16 | AuthState( 17 | isLoggedIn: isLoggedIn != null ? isLoggedIn() : this.isLoggedIn, 18 | username: username != null ? username() : this.username, 19 | ); 20 | 21 | @override 22 | List get props => [ 23 | isLoggedIn, 24 | username, 25 | ]; 26 | } 27 | -------------------------------------------------------------------------------- /packages/glider_data/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # VSCode related 19 | .vscode/ 20 | 21 | # Flutter/Dart/Pub related 22 | **/doc/api/ 23 | **/ios/Flutter/.last_build_id 24 | .dart_tool/ 25 | .flutter-plugins 26 | .flutter-plugins-dependencies 27 | .packages 28 | .pub-cache/ 29 | .pub/ 30 | /build/ 31 | pubspec.lock 32 | 33 | # Web related 34 | lib/generated_plugin_registrant.dart 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Test related 43 | coverage -------------------------------------------------------------------------------- /packages/glider_domain/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # VSCode related 19 | .vscode/ 20 | 21 | # Flutter/Dart/Pub related 22 | **/doc/api/ 23 | **/ios/Flutter/.last_build_id 24 | .dart_tool/ 25 | .flutter-plugins 26 | .flutter-plugins-dependencies 27 | .packages 28 | .pub-cache/ 29 | .pub/ 30 | /build/ 31 | pubspec.lock 32 | 33 | # Web related 34 | lib/generated_plugin_registrant.dart 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Test related 43 | coverage -------------------------------------------------------------------------------- /packages/glider_data/lib/src/secure_storage_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 2 | 3 | class SecureStorageService { 4 | const SecureStorageService(this._flutterSecureStorage); 5 | 6 | final FlutterSecureStorage _flutterSecureStorage; 7 | 8 | static const String _userCookieKey = 'userCookie'; 9 | 10 | Future getUserCookie() async => 11 | _flutterSecureStorage.read(key: _userCookieKey); 12 | 13 | Future setUserCookie(String value) async => 14 | _flutterSecureStorage.write(key: _userCookieKey, value: value); 15 | 16 | Future clearUserCookie() async => 17 | _flutterSecureStorage.delete(key: _userCookieKey); 18 | } 19 | -------------------------------------------------------------------------------- /lib/common/constants/app_animation.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | typedef Animation = ({Duration duration, Curve easing}); 4 | 5 | abstract final class AppAnimation { 6 | static Animation get emphasized => ( 7 | duration: disableAnimations ? Duration.zero : Durations.long2, 8 | // Emphasized easing is not yet defined in `Easing`. 9 | easing: Curves.easeInOutCubicEmphasized, 10 | ); 11 | 12 | static Animation get standard => ( 13 | duration: disableAnimations ? Duration.zero : Durations.medium2, 14 | easing: Easing.standard, 15 | ); 16 | 17 | static bool get disableAnimations => 18 | WidgetsBinding.instance.disableAnimations; 19 | } 20 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /lib/common/extensions/theme_data_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | extension ThemeDataExtension on ThemeData { 4 | Color elevationToColor(int elevation) => ElevationOverlay.applySurfaceTint( 5 | colorScheme.surface, 6 | colorScheme.surfaceTint, 7 | elevation.toDouble(), 8 | ); 9 | 10 | List? elevationToBoxShadow(int elevation) => 11 | kElevationToShadow.containsKey(elevation) 12 | ? kElevationToShadow[elevation] 13 | : null; 14 | 15 | BoxDecoration elevationToBoxDecoration(int elevation) => BoxDecoration( 16 | color: elevationToColor(elevation), 17 | boxShadow: elevationToBoxShadow(elevation), 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/long_description.txt: -------------------------------------------------------------------------------- 1 | Glider is an opinionated Hacker News client. Ad-free, open-source, no-nonsense. 2 | 3 | • Browse stories, comments and user profiles 4 | • Catch up on and search stories from any period 5 | • Log in using an existing or new Hacker News account 6 | • Vote on and favorite stories and comments 7 | • Write replies and submit new stories (experimental) 8 | • Collapse comment trees 9 | • No ads, no telemetry 10 | • Extensive theming 11 | • Sensible defaults 12 | 13 | Source code is available at https://github.com/Mosc/Glider. Don't hesitate to submit an issue if you'd like to see anything improved. 14 | 15 | Uses the official Hacker News API hosted on Firebase and the Hacker News Search API provided by Algolia. -------------------------------------------------------------------------------- /packages/glider_data/lib/src/dtos/user_dto.dart: -------------------------------------------------------------------------------- 1 | class UserDto { 2 | const UserDto({ 3 | required this.id, 4 | required this.created, 5 | required this.karma, 6 | this.about, 7 | this.submitted, 8 | }); 9 | 10 | factory UserDto.fromMap(Map json) => UserDto( 11 | id: json['id'] as String, 12 | created: json['created'] as int, 13 | karma: json['karma'] as int, 14 | about: json['about'] as String?, 15 | submitted: (json['submitted'] as List?) 16 | ?.map((e) => e as int) 17 | .toList(growable: false), 18 | ); 19 | 20 | final String id; 21 | final int created; 22 | final int karma; 23 | final String? about; 24 | final List? submitted; 25 | } 26 | -------------------------------------------------------------------------------- /lib/common/constants/app_spacing.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/painting.dart'; 2 | 3 | abstract final class AppSpacing { 4 | static const _baseUnit = 4.0; 5 | 6 | static const s = _baseUnit; 7 | 8 | static const m = _baseUnit * 2; 9 | 10 | static const l = _baseUnit * 3; 11 | 12 | static const xl = _baseUnit * 4; 13 | 14 | static const xxl = _baseUnit * 6; 15 | 16 | static const defaultTilePadding = EdgeInsets.symmetric( 17 | horizontal: xl, 18 | vertical: m, 19 | ); 20 | 21 | static const defaultShadowPadding = EdgeInsets.all(2); 22 | 23 | static const floatingActionButtonPageBottomPadding = 24 | EdgeInsets.only(bottom: 88); 25 | 26 | static const twoSmallFloatingActionButtonsPageBottomPadding = 27 | EdgeInsets.only(bottom: 136); 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch (debug)", 9 | "request": "launch", 10 | "type": "dart", 11 | "program": "lib/main.dart" 12 | }, 13 | { 14 | "name": "Launch (profile)", 15 | "request": "launch", 16 | "type": "dart", 17 | "flutterMode": "profile", 18 | "program": "lib/main.dart" 19 | }, 20 | { 21 | "name": "Launch (release)", 22 | "request": "launch", 23 | "type": "dart", 24 | "flutterMode": "release", 25 | "program": "lib/main.dart" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /lib/common/widgets/text_select_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | 5 | class TextSelectDialog extends StatelessWidget { 6 | const TextSelectDialog({super.key, required this.text}); 7 | 8 | final String text; 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return AlertDialog( 13 | title: Text(context.l10n.select), 14 | content: SingleChildScrollView( 15 | child: SelectionArea(child: Text(text)), 16 | ), 17 | actions: [ 18 | TextButton( 19 | onPressed: () => context.pop(true), 20 | child: Text(MaterialLocalizations.of(context).okButtonLabel), 21 | ), 22 | ], 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/common/widgets/loading_text_block.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:glider/common/extensions/text_style_extension.dart'; 3 | import 'package:glider/common/widgets/loading_block.dart'; 4 | 5 | class LoadingTextBlock extends StatelessWidget { 6 | const LoadingTextBlock({ 7 | super.key, 8 | this.width, 9 | this.style, 10 | this.hasLeading = true, 11 | }); 12 | 13 | final double? width; 14 | final TextStyle? style; 15 | final bool hasLeading; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Column( 20 | children: [ 21 | if (hasLeading) SizedBox(height: style?.leading(context)), 22 | LoadingBlock( 23 | width: width, 24 | height: style?.scaledFontSize(context), 25 | ), 26 | ], 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "8.3.0" apply false 22 | id "org.jetbrains.kotlin.android" version "1.9.23" apply false 23 | } 24 | 25 | include ":app" 26 | -------------------------------------------------------------------------------- /lib/edit/models/text_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:formz/formz.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum TextValidationError { empty } 6 | 7 | extension TextValidationErrorExtension on TextValidationError { 8 | String label(BuildContext context) { 9 | return switch (this) { 10 | TextValidationError.empty => context.l10n.emptyError, 11 | }; 12 | } 13 | } 14 | 15 | final class TextInput extends FormzInput { 16 | const TextInput.pure(super.value) : super.pure(); 17 | 18 | const TextInput.dirty(super.value) : super.dirty(); 19 | 20 | @override 21 | TextValidationError? validator(String value) => switch (value) { 22 | final text when text.isEmpty => TextValidationError.empty, 23 | _ => null, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /lib/reply/models/text_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:formz/formz.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum TextValidationError { empty } 6 | 7 | extension TextValidationErrorExtension on TextValidationError { 8 | String label(BuildContext context) { 9 | return switch (this) { 10 | TextValidationError.empty => context.l10n.emptyError, 11 | }; 12 | } 13 | } 14 | 15 | final class TextInput extends FormzInput { 16 | const TextInput.pure(super.value) : super.pure(); 17 | 18 | const TextInput.dirty(super.value) : super.dirty(); 19 | 20 | @override 21 | TextValidationError? validator(String value) => switch (value) { 22 | final text when text.isEmpty => TextValidationError.empty, 23 | _ => null, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /lib/common/widgets/empty_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_spacing.dart'; 3 | import 'package:glider/common/extensions/widget_list_extension.dart'; 4 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 5 | 6 | const _iconSize = 40.0; 7 | 8 | class EmptyWidget extends StatelessWidget { 9 | const EmptyWidget({super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Padding( 14 | padding: const EdgeInsets.all(AppSpacing.xl), 15 | child: Column( 16 | mainAxisAlignment: MainAxisAlignment.center, 17 | children: [ 18 | const Icon( 19 | Icons.air_outlined, 20 | size: _iconSize, 21 | ), 22 | Text(context.l10n.empty), 23 | ].spaced(height: AppSpacing.xl), 24 | ), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/app/extensions/super_sliver_list_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:super_sliver_list/super_sliver_list.dart'; 3 | 4 | extension SuperSliverListExtension on SuperSliverList { 5 | static SuperSliverList builder({ 6 | ListController? listController, 7 | required NullableIndexedWidgetBuilder itemBuilder, 8 | required int itemCount, 9 | }) => 10 | SuperSliverList( 11 | listController: listController, 12 | delegate: SliverChildBuilderDelegate( 13 | itemBuilder, 14 | childCount: itemCount, 15 | ), 16 | extentPrecalculationPolicy: AlwaysPrecalculateExtentPolicy(), 17 | layoutKeptAliveChildren: true, 18 | ); 19 | } 20 | 21 | class AlwaysPrecalculateExtentPolicy extends ExtentPrecalculationPolicy { 22 | @override 23 | bool shouldPrecalculateExtents(ExtentPrecalculationContext context) => true; 24 | } 25 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/package_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:glider_data/glider_data.dart'; 2 | import 'package:package_info_plus/package_info_plus.dart'; 3 | import 'package:pub_semver/pub_semver.dart'; 4 | 5 | class PackageRepository { 6 | const PackageRepository( 7 | this._sharedPreferencesService, 8 | this._packageInfo, 9 | ); 10 | 11 | final SharedPreferencesService _sharedPreferencesService; 12 | final PackageInfo _packageInfo; 13 | 14 | Version getVersion() => Version.parse(_packageInfo.version); 15 | 16 | Future getLastVersion() async { 17 | final lastVersion = await _sharedPreferencesService.getLastVersion(); 18 | return lastVersion != null ? Version.parse(lastVersion) : null; 19 | } 20 | 21 | Future setLastVersion({required Version value}) async => 22 | _sharedPreferencesService.setLastVersion( 23 | value: value.canonicalizedVersion, 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | tags-ignore: 7 | - 'v*' 8 | pull_request: 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Bootstrap workspace 19 | uses: ./.github/actions/bootstrap 20 | - name: Set up Java 21 | uses: actions/setup-java@v4 22 | with: 23 | distribution: temurin 24 | java-version: 21 25 | cache: gradle 26 | - name: Run static analysis checks 27 | run: melos lint 28 | - name: Build Android APK (profile) 29 | run: fvm flutter build apk --profile 30 | - name: Upload APK (profile) artifact 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: app-profile.apk 34 | path: build/app/outputs/apk/profile/app-profile.apk 35 | -------------------------------------------------------------------------------- /melos.yaml: -------------------------------------------------------------------------------- 1 | name: glider 2 | 3 | ide: 4 | intellij: 5 | enabled: false 6 | 7 | packages: 8 | - . 9 | - packages/* 10 | 11 | scripts: 12 | lint: 13 | run: | 14 | melos run analyze 15 | melos run format-check 16 | melos run dependency-validate 17 | description: Run all static analysis checks. 18 | 19 | analyze: 20 | exec: fvm dart analyze . --fatal-infos 21 | description: Run `dart analyze` in all packages. 22 | 23 | format: 24 | exec: fvm dart format . --fix 25 | description: Run `dart format` for all packages. 26 | 27 | format-check: 28 | exec: fvm dart format . --set-exit-if-changed 29 | description: Run `dart format` checks for all packages. 30 | 31 | dependency-validate: 32 | exec: fvm flutter pub run dependency_validator 33 | description: Run `flutter pub run dependency_validator` for all packages. 34 | 35 | outdated: 36 | exec: fvm flutter pub outdated 37 | description: Run `flutter pub outdated` for all packages. 38 | -------------------------------------------------------------------------------- /lib/submit/models/text_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:formz/formz.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum TextValidationError { empty } 6 | 7 | extension TextValidationErrorExtension on TextValidationError { 8 | String label(BuildContext context, {required String otherField}) { 9 | return switch (this) { 10 | TextValidationError.empty => context.l10n.bothEmptyError(otherField), 11 | }; 12 | } 13 | } 14 | 15 | final class TextInput extends FormzInput { 16 | const TextInput.pure(super.value, {this.url = ''}) : super.pure(); 17 | 18 | const TextInput.dirty(super.value, {required this.url}) : super.dirty(); 19 | 20 | final String url; 21 | 22 | @override 23 | TextValidationError? validator(String value) => switch (value) { 24 | final text when text.isEmpty && url.isEmpty => 25 | TextValidationError.empty, 26 | _ => null, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /lib/app/models/app_route.dart: -------------------------------------------------------------------------------- 1 | enum AppRoute { 2 | stories, 3 | catchUp, 4 | favorites, 5 | inbox, 6 | whatsNew, 7 | auth, 8 | settings, 9 | themeColorDialog(parent: settings), 10 | filtersDialog(parent: settings), 11 | submit, 12 | item, 13 | edit(parent: item), 14 | reply(parent: item), 15 | itemValueDialog(parent: item), 16 | user, 17 | userValueDialog(parent: user), 18 | textSelectDialog, 19 | confirmDialog; 20 | 21 | const AppRoute({this.parent}); 22 | 23 | final AppRoute? parent; 24 | 25 | String get path => [if (parent == null) '/', name].join(); 26 | 27 | String location({Map? parameters}) => Uri( 28 | path: [if (parent case final parent?) '${parent.path}/', path].join(), 29 | queryParameters: parameters != null 30 | ? { 31 | for (final parameter in parameters.entries) 32 | parameter.key: parameter.value?.toString(), 33 | } 34 | : null, 35 | ).toString(); 36 | } 37 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/auth_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:glider_data/glider_data.dart'; 2 | 3 | class AuthRepository { 4 | const AuthRepository( 5 | this._secureStorageService, 6 | this._sharedPreferencesService, 7 | ); 8 | 9 | final SecureStorageService _secureStorageService; 10 | final SharedPreferencesService _sharedPreferencesService; 11 | 12 | Future<(String? username, String? userCookie)> getUserAuth() async { 13 | final userCookie = await _secureStorageService.getUserCookie(); 14 | final username = userCookie?.split('&').first; 15 | return (username, userCookie); 16 | } 17 | 18 | Future login(String value) async => 19 | _secureStorageService.setUserCookie(value); 20 | 21 | Future logout() async { 22 | await _secureStorageService.clearUserCookie(); 23 | await _sharedPreferencesService.setUpvotedIds(ids: []); 24 | await _sharedPreferencesService.setFavoritedIds(ids: []); 25 | await _sharedPreferencesService.setFlaggedIds(ids: []); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/common/widgets/confirm_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | 5 | typedef ConfirmDialogExtra = ({String? title, String? text}); 6 | 7 | class ConfirmDialog extends StatelessWidget { 8 | const ConfirmDialog({super.key, this.title, this.text}); 9 | 10 | final String? title; 11 | final String? text; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return AlertDialog( 16 | title: Text(title ?? context.l10n.confirm), 17 | content: text != null ? SingleChildScrollView(child: Text(text!)) : null, 18 | actions: [ 19 | TextButton( 20 | onPressed: () => context.pop(false), 21 | child: Text(MaterialLocalizations.of(context).cancelButtonLabel), 22 | ), 23 | TextButton( 24 | onPressed: () => context.pop(true), 25 | child: Text(MaterialLocalizations.of(context).okButtonLabel), 26 | ), 27 | ], 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/navigation_shell/cubit/navigation_shell_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:bloc_presentation/bloc_presentation.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:glider_domain/glider_domain.dart'; 5 | 6 | part 'navigation_shell_cubit_event.dart'; 7 | part 'navigation_shell_state.dart'; 8 | 9 | class NavigationShellCubit extends Cubit 10 | with 11 | BlocPresentationMixin { 12 | NavigationShellCubit( 13 | this._packageRepository, 14 | ) : super(const NavigationShellState()); 15 | 16 | final PackageRepository _packageRepository; 17 | 18 | Future init() async { 19 | final version = _packageRepository.getVersion(); 20 | final lastVersion = await _packageRepository.getLastVersion(); 21 | 22 | if (version != lastVersion) { 23 | await _packageRepository.setLastVersion(value: version); 24 | 25 | if (lastVersion != null && version >= lastVersion.nextMajor) { 26 | emitPresentation(ShowWhatsNewEvent()); 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/stories_search/bloc/stories_search_event.dart: -------------------------------------------------------------------------------- 1 | part of 'stories_search_bloc.dart'; 2 | 3 | sealed class StoriesSearchEvent with EquatableMixin { 4 | const StoriesSearchEvent(); 5 | } 6 | 7 | final class LoadStoriesSearchEvent extends StoriesSearchEvent { 8 | const LoadStoriesSearchEvent(); 9 | 10 | @override 11 | List get props => []; 12 | } 13 | 14 | final class SetTextStoriesSearchEvent extends StoriesSearchEvent { 15 | const SetTextStoriesSearchEvent(this.text); 16 | 17 | final String? text; 18 | 19 | @override 20 | List get props => [text]; 21 | } 22 | 23 | final class SetSearchRangeStoriesSearchEvent extends StoriesSearchEvent { 24 | const SetSearchRangeStoriesSearchEvent(this.searchRange); 25 | 26 | final SearchRange? searchRange; 27 | 28 | @override 29 | List get props => [searchRange]; 30 | } 31 | 32 | final class SetDateRangeStoriesSearchEvent extends StoriesSearchEvent { 33 | const SetDateRangeStoriesSearchEvent(this.dateRange); 34 | 35 | final DateTimeRange? dateRange; 36 | 37 | @override 38 | List get props => [dateRange]; 39 | } 40 | -------------------------------------------------------------------------------- /lib/edit/models/title_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:formz/formz.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum TitleValidationError { empty, tooLong } 6 | 7 | extension TitleValidationErrorExtension on TitleValidationError { 8 | String label(BuildContext context) { 9 | return switch (this) { 10 | TitleValidationError.empty => context.l10n.emptyError, 11 | TitleValidationError.tooLong => 12 | context.l10n.tooLongError(TitleInput.maxLength), 13 | }; 14 | } 15 | } 16 | 17 | final class TitleInput extends FormzInput { 18 | const TitleInput.pure(super.value) : super.pure(); 19 | 20 | const TitleInput.dirty(super.value) : super.dirty(); 21 | 22 | static const maxLength = 80; 23 | 24 | @override 25 | TitleValidationError? validator(String value) => switch (value) { 26 | final title when title.isEmpty => TitleValidationError.empty, 27 | final title when title.length > maxLength => 28 | TitleValidationError.tooLong, 29 | _ => null, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /lib/submit/models/title_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:formz/formz.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum TitleValidationError { empty, tooLong } 6 | 7 | extension TitleValidationErrorExtension on TitleValidationError { 8 | String label(BuildContext context) { 9 | return switch (this) { 10 | TitleValidationError.empty => context.l10n.emptyError, 11 | TitleValidationError.tooLong => 12 | context.l10n.tooLongError(TitleInput.maxLength), 13 | }; 14 | } 15 | } 16 | 17 | final class TitleInput extends FormzInput { 18 | const TitleInput.pure(super.value) : super.pure(); 19 | 20 | const TitleInput.dirty(super.value) : super.dirty(); 21 | 22 | static const maxLength = 80; 23 | 24 | @override 25 | TitleValidationError? validator(String value) => switch (value) { 26 | final title when title.isEmpty => TitleValidationError.empty, 27 | final title when title.length > maxLength => 28 | TitleValidationError.tooLong, 29 | _ => null, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Daniel Moscoviter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/entities/item_descendant.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class ItemDescendant with EquatableMixin { 4 | ItemDescendant({ 5 | required this.id, 6 | this.ancestorIds = const [], 7 | this.isPart = false, 8 | }); 9 | 10 | factory ItemDescendant.fromMap(Map json) => ItemDescendant( 11 | id: json['id'] as int, 12 | ancestorIds: (json['ancestorIds'] as List?) 13 | ?.map((e) => e as int) 14 | .toList(growable: false) ?? 15 | const [], 16 | isPart: json['isPart'] as bool? ?? false, 17 | ); 18 | 19 | Map toMap() => { 20 | 'id': id, 21 | 'ancestorIds': ancestorIds, 22 | 'isPart': isPart, 23 | }; 24 | 25 | final int id; 26 | final List ancestorIds; 27 | final bool isPart; 28 | 29 | @override 30 | List get props => [ 31 | id, 32 | ancestorIds, 33 | isPart, 34 | ]; 35 | } 36 | 37 | extension ItemDescendantExtension on ItemDescendant { 38 | int get depth => ancestorIds.length; 39 | } 40 | -------------------------------------------------------------------------------- /lib/user_item_search/bloc/user_item_search_state.dart: -------------------------------------------------------------------------------- 1 | part of 'user_item_search_bloc.dart'; 2 | 3 | class UserItemSearchState with DataMixin>, EquatableMixin { 4 | const UserItemSearchState({ 5 | this.status = Status.initial, 6 | this.data, 7 | this.searchText, 8 | this.exception, 9 | }); 10 | 11 | @override 12 | final Status status; 13 | @override 14 | final List? data; 15 | final String? searchText; 16 | @override 17 | final Object? exception; 18 | 19 | UserItemSearchState copyWith({ 20 | Status Function()? status, 21 | List? Function()? data, 22 | String? Function()? searchText, 23 | Object? Function()? exception, 24 | }) => 25 | UserItemSearchState( 26 | status: status != null ? status() : this.status, 27 | data: data != null ? data() : this.data, 28 | searchText: searchText != null ? searchText() : this.searchText, 29 | exception: exception != null ? exception() : this.exception, 30 | ); 31 | 32 | @override 33 | List get props => [ 34 | status, 35 | data, 36 | searchText, 37 | exception, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /lib/story_item_search/bloc/story_item_search_state.dart: -------------------------------------------------------------------------------- 1 | part of 'story_item_search_bloc.dart'; 2 | 3 | class StoryItemSearchState with DataMixin>, EquatableMixin { 4 | const StoryItemSearchState({ 5 | this.status = Status.initial, 6 | this.data, 7 | this.searchText, 8 | this.exception, 9 | }); 10 | 11 | @override 12 | final Status status; 13 | @override 14 | final List? data; 15 | final String? searchText; 16 | @override 17 | final Object? exception; 18 | 19 | StoryItemSearchState copyWith({ 20 | Status Function()? status, 21 | List? Function()? data, 22 | String? Function()? searchText, 23 | Object? Function()? exception, 24 | }) => 25 | StoryItemSearchState( 26 | status: status != null ? status() : this.status, 27 | data: data != null ? data() : this.data, 28 | searchText: searchText != null ? searchText() : this.searchText, 29 | exception: exception != null ? exception() : this.exception, 30 | ); 31 | 32 | @override 33 | List get props => [ 34 | status, 35 | data, 36 | searchText, 37 | exception, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /macos/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_COPYRIGHT) 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/user_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:compute/compute.dart'; 4 | import 'package:glider_data/glider_data.dart'; 5 | import 'package:glider_domain/src/entities/user.dart'; 6 | import 'package:glider_domain/src/extensions/behavior_subject_map_extension.dart'; 7 | import 'package:rxdart/subjects.dart'; 8 | 9 | class UserRepository { 10 | UserRepository(this._hackerNewsApiService) : _userStreamControllers = {}; 11 | 12 | final HackerNewsApiService _hackerNewsApiService; 13 | 14 | final Map> _userStreamControllers; 15 | 16 | Stream getUserStream(String username) => _userStreamControllers 17 | .getOrAdd(username, asyncSeed: () async => getUser(username)) 18 | .stream; 19 | 20 | Future getUser(String username) async { 21 | try { 22 | final dto = await _hackerNewsApiService.getUser(username); 23 | final user = await compute(User.fromDto, dto); 24 | _userStreamControllers.getOrAdd(username).add(user); 25 | return user; 26 | } on Object catch (e, st) { 27 | _userStreamControllers.getOrAdd(username).addError(e, st); 28 | rethrow; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/submit/models/url_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:formz/formz.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum UrlValidationError { empty, invalid } 6 | 7 | extension UrlValidationErrorExtension on UrlValidationError { 8 | String label(BuildContext context, {required String otherField}) { 9 | return switch (this) { 10 | UrlValidationError.empty => context.l10n.bothEmptyError(otherField), 11 | UrlValidationError.invalid => context.l10n.invalidUrlError, 12 | }; 13 | } 14 | } 15 | 16 | final class UrlInput extends FormzInput { 17 | const UrlInput.pure(super.value, {this.text = ''}) : super.pure(); 18 | 19 | const UrlInput.dirty(super.value, {required this.text}) : super.dirty(); 20 | 21 | final String text; 22 | 23 | @override 24 | UrlValidationError? validator(String value) => switch (value) { 25 | final url when url.isEmpty && text.isEmpty => UrlValidationError.empty, 26 | final url when url.isNotEmpty && !hasHost => UrlValidationError.invalid, 27 | _ => null, 28 | }; 29 | 30 | bool get hasHost => Uri.tryParse(value)?.host.isNotEmpty ?? false; 31 | } 32 | -------------------------------------------------------------------------------- /lib/app/models/dialog_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DialogPage extends Page { 4 | const DialogPage({ 5 | super.key, 6 | super.name, 7 | super.arguments, 8 | super.restorationId, 9 | required this.builder, 10 | this.themes, 11 | this.barrierColor = Colors.black54, 12 | this.barrierDismissible = true, 13 | this.barrierLabel, 14 | this.useSafeArea = true, 15 | this.anchorPoint, 16 | this.traversalEdgeBehavior, 17 | }); 18 | 19 | final WidgetBuilder builder; 20 | final CapturedThemes? themes; 21 | final Color? barrierColor; 22 | final bool barrierDismissible; 23 | final String? barrierLabel; 24 | final bool useSafeArea; 25 | final Offset? anchorPoint; 26 | final TraversalEdgeBehavior? traversalEdgeBehavior; 27 | 28 | @override 29 | Route createRoute(BuildContext context) => DialogRoute( 30 | context: context, 31 | builder: builder, 32 | themes: themes, 33 | barrierColor: barrierColor, 34 | barrierDismissible: barrierDismissible, 35 | barrierLabel: barrierLabel, 36 | useSafeArea: useSafeArea, 37 | settings: this, 38 | anchorPoint: anchorPoint, 39 | traversalEdgeBehavior: traversalEdgeBehavior, 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /lib/common/widgets/metadata_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_spacing.dart'; 3 | import 'package:glider/common/extensions/widget_list_extension.dart'; 4 | 5 | const _iconSize = 16.0; 6 | 7 | class MetadataWidget extends StatelessWidget { 8 | const MetadataWidget({ 9 | super.key, 10 | this.icon, 11 | this.label, 12 | this.color, 13 | }); 14 | 15 | final IconData? icon; 16 | final Widget? label; 17 | final Color? color; 18 | 19 | static const horizontalPadding = 20 | EdgeInsetsDirectional.only(end: AppSpacing.m); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Row( 25 | mainAxisSize: MainAxisSize.min, 26 | children: [ 27 | if (icon != null) 28 | Icon( 29 | icon, 30 | size: _iconSize, 31 | color: color, 32 | ), 33 | if (label != null) 34 | Flexible( 35 | child: DefaultTextStyle( 36 | style: 37 | Theme.of(context).textTheme.bodySmall!.copyWith(color: color), 38 | maxLines: 1, 39 | overflow: TextOverflow.ellipsis, 40 | child: label!, 41 | ), 42 | ), 43 | ].spaced(width: AppSpacing.s), 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/favorites/cubit/favorites_state.dart: -------------------------------------------------------------------------------- 1 | part of 'favorites_cubit.dart'; 2 | 3 | class FavoritesState with DataMixin>, EquatableMixin { 4 | const FavoritesState({ 5 | this.status = Status.initial, 6 | this.data, 7 | this.exception, 8 | }); 9 | 10 | factory FavoritesState.fromMap(Map json) => FavoritesState( 11 | status: Status.values.byName(json['status'] as String), 12 | data: (json['data'] as List?) 13 | ?.map((e) => e as int) 14 | .toList(growable: false), 15 | ); 16 | 17 | Map toMap() => { 18 | 'status': status.name, 19 | 'data': data, 20 | }; 21 | 22 | @override 23 | final Status status; 24 | @override 25 | final List? data; 26 | @override 27 | final Object? exception; 28 | 29 | FavoritesState copyWith({ 30 | Status Function()? status, 31 | List? Function()? data, 32 | Object? Function()? exception, 33 | }) => 34 | FavoritesState( 35 | status: status != null ? status() : this.status, 36 | data: data != null ? data() : this.data, 37 | exception: exception != null ? exception() : this.exception, 38 | ); 39 | 40 | @override 41 | List get props => [ 42 | status, 43 | data, 44 | exception, 45 | ]; 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glider for Hacker News 2 | 3 | [Get it on Google Play][play store] 4 | [Get it on F-Droid][f-droid] 5 | 6 | Glider is an opinionated Hacker News client. Ad-free, open-source, no-nonsense. 7 | 8 | - Browse stories, comments and user profiles 9 | - Catch up on and search stories from any period 10 | - Log in using an existing or new Hacker News account 11 | - Vote on and favorite stories and comments 12 | - Write replies and submit new stories 13 | - Collapse comment trees 14 | - No ads, no telemetry 15 | - Extensive theming 16 | - Sensible defaults 17 | 18 |

19 | 20 | 21 | 22 | 23 | 24 |

25 | 26 | [play store]: https://play.google.com/store/apps/details?id=nl.viter.glider 27 | [f-droid]: https://f-droid.org/packages/nl.viter.glider 28 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "1751123cde4ffad08ae27bdee4f8ddebd033fe76" 8 | channel: "beta" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 17 | base_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 18 | - platform: android 19 | create_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 20 | base_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 21 | - platform: ios 22 | create_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 23 | base_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 24 | - platform: macos 25 | create_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 26 | base_revision: 1751123cde4ffad08ae27bdee4f8ddebd033fe76 27 | 28 | # User provided section 29 | 30 | # List of Local paths (relative to this file) that should be 31 | # ignored by the migrate tool. 32 | # 33 | # Files that are not part of the templates will be ignored by default. 34 | unmanaged_files: 35 | - 'lib/main.dart' 36 | - 'ios/Runner.xcodeproj/project.pbxproj' 37 | -------------------------------------------------------------------------------- /macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | import device_info_plus 9 | import dynamic_color 10 | import flutter_inappwebview_macos 11 | import flutter_secure_storage_macos 12 | import package_info_plus 13 | import path_provider_foundation 14 | import share_plus 15 | import shared_preferences_foundation 16 | import url_launcher_macos 17 | 18 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 19 | DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) 20 | DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) 21 | InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) 22 | FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) 23 | FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 24 | PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 25 | SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) 26 | SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) 27 | UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) 28 | } 29 | -------------------------------------------------------------------------------- /lib/common/widgets/refreshable_scroll_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | // This value happens to fit a page worth of items (30) with the standard height 4 | // of an item in the stories overview (92). It does not appear to have a 5 | // significant negative impact on initial load performance, while making 6 | // scrolling noticably smoother on most affected pages compared to the default. 7 | const _cacheExtent = 2760.0; 8 | 9 | class RefreshableScrollView extends StatelessWidget { 10 | const RefreshableScrollView({ 11 | super.key, 12 | this.scrollController, 13 | required this.slivers, 14 | required this.onRefresh, 15 | this.toolbarHeight, 16 | this.edgeOffset, 17 | }); 18 | 19 | final ScrollController? scrollController; 20 | final List slivers; 21 | final RefreshCallback onRefresh; 22 | final double? toolbarHeight; 23 | final double? edgeOffset; 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return RefreshIndicator( 28 | onRefresh: onRefresh, 29 | displacement: toolbarHeight ?? kToolbarHeight, 30 | edgeOffset: edgeOffset ?? MediaQuery.paddingOf(context).top, 31 | child: CustomScrollView( 32 | controller: scrollController, 33 | physics: const AlwaysScrollableScrollPhysics(), 34 | cacheExtent: _cacheExtent, 35 | slivers: slivers, 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/common/widgets/app_bar_progress_indicator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:glider/common/constants/app_animation.dart'; 4 | import 'package:glider/common/mixins/data_mixin.dart'; 5 | import 'package:glider/common/models/status.dart'; 6 | 7 | const _height = 2.0; 8 | 9 | class AppBarProgressIndicator, 10 | S extends DataMixin> extends StatelessWidget { 11 | const AppBarProgressIndicator(this._bloc, {super.key}); 12 | 13 | final B _bloc; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return BlocSelector( 18 | bloc: _bloc, 19 | selector: (state) => state.status == Status.loading, 20 | builder: (context, isLoading) => AnimatedOpacity( 21 | opacity: isLoading ? 1 : 0, 22 | duration: AppAnimation.standard.duration, 23 | curve: AppAnimation.standard.easing, 24 | child: Padding( 25 | padding: EdgeInsets.only( 26 | top: MediaQuery.paddingOf(context).top + kToolbarHeight - _height, 27 | ), 28 | child: TickerMode( 29 | enabled: isLoading, 30 | child: const LinearProgressIndicator( 31 | minHeight: _height, 32 | backgroundColor: Colors.transparent, 33 | ), 34 | ), 35 | ), 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /macos/Podfile: -------------------------------------------------------------------------------- 1 | platform :osx, '10.14' 2 | 3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 | 6 | project 'Runner', { 7 | 'Debug' => :debug, 8 | 'Profile' => :release, 9 | 'Release' => :release, 10 | } 11 | 12 | def flutter_root 13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) 14 | unless File.exist?(generated_xcode_build_settings_path) 15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" 16 | end 17 | 18 | File.foreach(generated_xcode_build_settings_path) do |line| 19 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 20 | return matches[1].strip if matches 21 | end 22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" 23 | end 24 | 25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 26 | 27 | flutter_macos_podfile_setup 28 | 29 | target 'Runner' do 30 | use_frameworks! 31 | use_modular_headers! 32 | 33 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) 34 | end 35 | 36 | post_install do |installer| 37 | installer.pods_project.targets.each do |target| 38 | flutter_additional_macos_build_settings(target) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '12.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/common/widgets/preview_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_animation.dart'; 3 | import 'package:glider/common/constants/app_spacing.dart'; 4 | import 'package:glider/common/extensions/widget_list_extension.dart'; 5 | import 'package:glider/common/widgets/metadata_widget.dart'; 6 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 7 | 8 | class PreviewCard extends StatelessWidget { 9 | const PreviewCard({ 10 | super.key, 11 | required this.child, 12 | }); 13 | 14 | final Widget child; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Card.outlined( 19 | child: Column( 20 | crossAxisAlignment: CrossAxisAlignment.start, 21 | children: [ 22 | Padding( 23 | padding: AppSpacing.defaultTilePadding.copyWith(bottom: 0), 24 | child: Row( 25 | children: [ 26 | const MetadataWidget(icon: Icons.visibility_outlined), 27 | Text( 28 | context.l10n.preview, 29 | style: Theme.of(context).textTheme.titleSmall, 30 | ), 31 | ].spaced(width: AppSpacing.l), 32 | ), 33 | ), 34 | AnimatedSize( 35 | alignment: Alignment.topCenter, 36 | duration: AppAnimation.emphasized.duration, 37 | curve: AppAnimation.emphasized.easing, 38 | child: child, 39 | ), 40 | ], 41 | ), 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/settings/widgets/menu_list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class MenuListTile extends StatelessWidget { 4 | const MenuListTile({ 5 | super.key, 6 | this.title, 7 | this.trailing, 8 | this.enabled = true, 9 | this.onChanged, 10 | required this.values, 11 | required this.selected, 12 | required this.childBuilder, 13 | }); 14 | 15 | final Widget? title; 16 | final Widget? trailing; 17 | final bool enabled; 18 | final void Function(T)? onChanged; 19 | final Iterable values; 20 | final bool Function(T) selected; 21 | final Widget Function(T) childBuilder; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return MenuAnchor( 26 | style: Theme.of(context) 27 | .menuTheme 28 | .style 29 | ?.copyWith(alignment: AlignmentDirectional.bottomEnd), 30 | menuChildren: [ 31 | for (final value in values) 32 | MenuItemButton( 33 | onPressed: () => onChanged?.call(value), 34 | leadingIcon: Visibility.maintain( 35 | visible: selected(value), 36 | child: const Icon(Icons.check_outlined), 37 | ), 38 | child: childBuilder(value), 39 | ), 40 | ], 41 | builder: (context, controller, child) => ListTile( 42 | title: title, 43 | trailing: trailing, 44 | enabled: enabled, 45 | onTap: () async => 46 | controller.isOpen ? controller.close() : controller.open(), 47 | ), 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/inbox/cubit/inbox_state.dart: -------------------------------------------------------------------------------- 1 | part of 'inbox_cubit.dart'; 2 | 3 | typedef IdWithParent = (int parentId, int id); 4 | 5 | class InboxState with DataMixin>, EquatableMixin { 6 | const InboxState({ 7 | this.status = Status.initial, 8 | this.data, 9 | this.exception, 10 | }); 11 | 12 | factory InboxState.fromMap(Map json) => InboxState( 13 | status: Status.values.byName(json['status'] as String), 14 | data: (json['data'] as List?) 15 | ?.map((e) => e as Map) 16 | .map((e) => (e['parentId'] as int, e['id'] as int)) 17 | .toList(growable: false), 18 | ); 19 | 20 | Map toMap() => { 21 | 'status': status.name, 22 | 'data': data 23 | ?.map((e) => {'parentId': e.$1, 'id': e.$2}) 24 | .toList(growable: false), 25 | }; 26 | 27 | @override 28 | final Status status; 29 | @override 30 | final List? data; 31 | @override 32 | final Object? exception; 33 | 34 | InboxState copyWith({ 35 | Status Function()? status, 36 | List? Function()? data, 37 | Object? Function()? exception, 38 | }) => 39 | InboxState( 40 | status: status != null ? status() : this.status, 41 | data: data != null ? data() : this.data, 42 | exception: exception != null ? exception() : this.exception, 43 | ); 44 | 45 | @override 46 | List get props => [ 47 | status, 48 | data, 49 | exception, 50 | ]; 51 | } 52 | -------------------------------------------------------------------------------- /lib/stories/models/story_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 3 | 4 | enum StoryType { 5 | topStories, 6 | newStories, 7 | bestStories, 8 | askStories, 9 | showStories, 10 | jobStories; 11 | 12 | String label(BuildContext context) { 13 | return switch (this) { 14 | StoryType.topStories => context.l10n.storyTypeTop, 15 | StoryType.newStories => context.l10n.storyTypeNew, 16 | StoryType.bestStories => context.l10n.storyTypeBest, 17 | StoryType.askStories => context.l10n.storyTypeAsk, 18 | StoryType.showStories => context.l10n.storyTypeShow, 19 | StoryType.jobStories => context.l10n.storyTypeJob, 20 | }; 21 | } 22 | 23 | IconData get icon { 24 | return switch (this) { 25 | StoryType.topStories => Icons.whatshot_outlined, 26 | StoryType.newStories => Icons.new_releases_outlined, 27 | StoryType.bestStories => Icons.recommend_outlined, 28 | StoryType.askStories => Icons.help_outline_outlined, 29 | StoryType.showStories => Icons.play_circle_outlined, 30 | StoryType.jobStories => Icons.monetization_on_outlined, 31 | }; 32 | } 33 | 34 | IconData get selectedIcon { 35 | return switch (this) { 36 | StoryType.topStories => Icons.whatshot, 37 | StoryType.newStories => Icons.new_releases, 38 | StoryType.bestStories => Icons.recommend, 39 | StoryType.askStories => Icons.help, 40 | StoryType.showStories => Icons.play_circle, 41 | StoryType.jobStories => Icons.monetization_on, 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/common/extensions/uri_extension.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:glider/common/constants/app_uris.dart'; 5 | import 'package:go_router/go_router.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | 8 | extension UriExtension on Uri { 9 | Future tryLaunch( 10 | BuildContext context, { 11 | String? title, 12 | required bool useInAppBrowser, 13 | }) async { 14 | if (authority == AppUris.hackerNewsUri.authority) { 15 | unawaited(context.push(toString())); 16 | return true; 17 | } 18 | 19 | if (await canLaunchUrl(this)) { 20 | if (await supportsLaunchMode(LaunchMode.externalNonBrowserApplication)) { 21 | final success = await launchUrl( 22 | this, 23 | mode: LaunchMode.externalNonBrowserApplication, 24 | webOnlyWindowName: title, 25 | ); 26 | if (success) return true; 27 | } 28 | 29 | if (useInAppBrowser && 30 | await supportsLaunchMode(LaunchMode.inAppBrowserView)) { 31 | final success = await launchUrl( 32 | this, 33 | mode: LaunchMode.inAppBrowserView, 34 | webOnlyWindowName: title, 35 | ); 36 | if (success) return true; 37 | } 38 | 39 | if (await supportsLaunchMode(LaunchMode.externalApplication)) { 40 | final success = await launchUrl( 41 | this, 42 | mode: LaunchMode.externalApplication, 43 | webOnlyWindowName: title, 44 | ); 45 | if (success) return true; 46 | } 47 | } 48 | 49 | return false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/navigation_shell/models/navigation_shell_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/app/models/app_route.dart'; 3 | import 'package:glider/auth/cubit/auth_cubit.dart'; 4 | import 'package:glider/common/interfaces/menu_item.dart'; 5 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 6 | import 'package:glider/settings/cubit/settings_cubit.dart'; 7 | import 'package:go_router/go_router.dart'; 8 | 9 | enum NavigationShellAction implements MenuItem { 10 | settings, 11 | account; 12 | 13 | const NavigationShellAction(); 14 | 15 | @override 16 | bool isVisible(void _, AuthState authState, SettingsState settingsState) { 17 | return switch (this) { 18 | NavigationShellAction.settings || NavigationShellAction.account => true, 19 | }; 20 | } 21 | 22 | @override 23 | String label(BuildContext context, void _) { 24 | return switch (this) { 25 | NavigationShellAction.settings => context.l10n.settings, 26 | NavigationShellAction.account => context.l10n.account, 27 | }; 28 | } 29 | 30 | @override 31 | IconData icon(void _) { 32 | return switch (this) { 33 | NavigationShellAction.settings => Icons.settings_outlined, 34 | NavigationShellAction.account => Icons.account_circle_outlined, 35 | }; 36 | } 37 | 38 | Future execute(BuildContext context) async { 39 | switch (this) { 40 | case NavigationShellAction.settings: 41 | await context.push(AppRoute.settings.location()); 42 | case NavigationShellAction.account: 43 | await context.push(AppRoute.auth.location()); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/glider_data/lib/src/dtos/item_dto.dart: -------------------------------------------------------------------------------- 1 | class ItemDto { 2 | const ItemDto({ 3 | required this.id, 4 | this.deleted, 5 | this.type, 6 | this.by, 7 | this.time, 8 | this.text, 9 | this.dead, 10 | this.parent, 11 | this.poll, 12 | this.kids, 13 | this.url, 14 | this.score, 15 | this.title, 16 | this.parts, 17 | this.descendants, 18 | }); 19 | 20 | factory ItemDto.fromMap(Map json) => ItemDto( 21 | id: json['id'] as int, 22 | deleted: json['deleted'] as bool?, 23 | type: json['type'] as String?, 24 | by: json['by'] as String?, 25 | time: json['time'] as int?, 26 | text: json['text'] as String?, 27 | dead: json['dead'] as bool?, 28 | parent: json['parent'] as int?, 29 | poll: json['poll'] as int?, 30 | kids: (json['kids'] as List?) 31 | ?.map((e) => e as int) 32 | .toList(growable: false), 33 | url: json['url'] as String?, 34 | score: json['score'] as int?, 35 | title: json['title'] as String?, 36 | parts: (json['parts'] as List?) 37 | ?.map((e) => e as int) 38 | .toList(growable: false), 39 | descendants: json['descendants'] as int?, 40 | ); 41 | 42 | final int id; 43 | final bool? deleted; 44 | final String? type; 45 | final String? by; 46 | final int? time; 47 | final String? text; 48 | final bool? dead; 49 | final int? parent; 50 | final int? poll; 51 | final List? kids; 52 | final String? url; 53 | final int? score; 54 | final String? title; 55 | final List? parts; 56 | final int? descendants; 57 | } 58 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/story_similar/cubit/story_similar_state.dart: -------------------------------------------------------------------------------- 1 | part of 'story_similar_cubit.dart'; 2 | 3 | class StorySimilarState with DataMixin>, EquatableMixin { 4 | const StorySimilarState({ 5 | this.status = Status.initial, 6 | this.item, 7 | this.data, 8 | this.exception, 9 | }); 10 | 11 | factory StorySimilarState.fromMap(Map json) => 12 | StorySimilarState( 13 | status: Status.values.byName(json['status'] as String), 14 | item: json['item'] != null 15 | ? Item.fromMap(json['item'] as Map) 16 | : null, 17 | data: (json['data'] as List?) 18 | ?.map((e) => e as int) 19 | .toList(growable: false), 20 | ); 21 | 22 | Map toMap() => { 23 | 'status': status.name, 24 | 'item': item?.toMap(), 25 | 'data': data, 26 | }; 27 | 28 | @override 29 | final Status status; 30 | final Item? item; 31 | @override 32 | final List? data; 33 | @override 34 | final Object? exception; 35 | 36 | StorySimilarState copyWith({ 37 | Status Function()? status, 38 | Item? Function()? item, 39 | List? Function()? data, 40 | Object? Function()? exception, 41 | }) => 42 | StorySimilarState( 43 | status: status != null ? status() : this.status, 44 | item: item != null ? item() : this.item, 45 | data: data != null ? data() : this.data, 46 | exception: exception != null ? exception() : this.exception, 47 | ); 48 | 49 | @override 50 | List get props => [ 51 | status, 52 | item, 53 | data, 54 | exception, 55 | ]; 56 | } 57 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: glider 2 | description: An opinionated Hacker News client. 3 | version: 2.8.0+49 4 | publish_to: none 5 | 6 | environment: 7 | sdk: ">=3.3.0-0 <4.0.0" 8 | 9 | dependencies: 10 | bloc: ^8.1.4 11 | bloc_presentation: ^1.0.0 12 | clock: ^1.1.1 13 | collection: any 14 | device_info_plus: ^10.1.0 15 | dynamic_color: ^1.7.0 16 | equatable: ^2.0.5 17 | flutter: 18 | sdk: flutter 19 | flutter_adaptive_scaffold: ^0.1.10+1 20 | flutter_bloc: ^8.1.5 21 | flutter_displaymode: ^0.6.0 22 | flutter_inappwebview: ^6.0.0 23 | flutter_localizations: 24 | sdk: flutter 25 | flutter_markdown: ^0.6.22+1 26 | flutter_secure_storage: ^9.0.0 27 | formz: ^0.7.0 28 | glider_data: 29 | path: packages/glider_data 30 | glider_domain: 31 | path: packages/glider_domain 32 | go_router: ^13.2.3 33 | google_fonts: ^6.2.1 34 | http: ^1.2.1 35 | hydrated_bloc: ^9.1.5 36 | intl: any 37 | markdown: ^7.2.2 38 | material_color_utilities: any 39 | package_info_plus: ^7.0.0 40 | path_provider: ^2.1.2 41 | pub_semver: ^2.1.4 42 | relative_time: ^5.0.0 43 | rxdart: ^0.27.7 44 | share_plus: ^8.0.3 45 | shared_preferences: ^2.2.2 46 | sliver_tools: ^0.2.12 47 | super_sliver_list: ^0.4.1 48 | url_launcher: ^6.2.5 49 | 50 | dev_dependencies: 51 | custom_lint: ^0.6.4 52 | dependency_validator: ^3.2.3 53 | flutter_launcher_icons: ^0.13.1 54 | leancode_lint: ^12.0.0 55 | melos: ^5.3.0 56 | 57 | flutter: 58 | uses-material-design: true 59 | generate: true 60 | fonts: 61 | - family: NotoSans 62 | fonts: 63 | - asset: assets/fonts/NotoSans-Regular.ttf 64 | - family: NotoSansMono 65 | fonts: 66 | - asset: assets/fonts/NotoSansMono-Regular.ttf 67 | -------------------------------------------------------------------------------- /lib/reply/cubit/reply_state.dart: -------------------------------------------------------------------------------- 1 | part of 'reply_cubit.dart'; 2 | 3 | class ReplyState with EquatableMixin { 4 | const ReplyState({ 5 | this.parentItem, 6 | this.text = const TextInput.pure(''), 7 | this.isValid = false, 8 | this.preview = true, 9 | this.success = false, 10 | }); 11 | 12 | factory ReplyState.fromMap(Map json) => ReplyState( 13 | parentItem: json['parentItem'] != null 14 | ? Item.fromMap(json['parentItem'] as Map) 15 | : null, 16 | text: TextInput.pure(json['text'] as String? ?? ''), 17 | isValid: json['isValid'] as bool? ?? false, 18 | ); 19 | 20 | Map toMap() => { 21 | 'parentItem': parentItem, 22 | 'text': text.value, 23 | 'isValid': isValid, 24 | }; 25 | 26 | final Item? parentItem; 27 | final TextInput text; 28 | final bool isValid; 29 | final bool preview; 30 | final bool success; 31 | 32 | ReplyState copyWith({ 33 | Item Function()? parentItem, 34 | TextInput Function()? text, 35 | bool Function()? isValid, 36 | bool Function()? preview, 37 | bool Function()? success, 38 | }) => 39 | ReplyState( 40 | parentItem: parentItem != null ? parentItem() : this.parentItem, 41 | text: text != null ? text() : this.text, 42 | isValid: isValid != null ? isValid() : this.isValid, 43 | preview: preview != null ? preview() : this.preview, 44 | success: success != null ? success() : this.success, 45 | ); 46 | 47 | @override 48 | List get props => [ 49 | parentItem, 50 | text, 51 | isValid, 52 | preview, 53 | success, 54 | ]; 55 | } 56 | -------------------------------------------------------------------------------- /packages/glider_data/lib/src/dtos/algolia_search_dto.dart: -------------------------------------------------------------------------------- 1 | class AlgoliaSearchDto { 2 | const AlgoliaSearchDto({required this.hits}); 3 | 4 | factory AlgoliaSearchDto.fromMap(Map json) => 5 | AlgoliaSearchDto( 6 | hits: (json['hits'] as List) 7 | .map( 8 | (e) => AlgoliaSearchHitDto.fromMap(e as Map), 9 | ) 10 | .toList(growable: false), 11 | ); 12 | 13 | final List hits; 14 | } 15 | 16 | class AlgoliaSearchHitDto { 17 | const AlgoliaSearchHitDto({ 18 | required this.objectId, 19 | this.title, 20 | this.url, 21 | this.author, 22 | this.points, 23 | this.storyText, 24 | this.commentText, 25 | this.numComments, 26 | this.parentId, 27 | this.createdAtI, 28 | }); 29 | 30 | factory AlgoliaSearchHitDto.fromMap(Map json) => 31 | AlgoliaSearchHitDto( 32 | objectId: json['objectID'] as String, 33 | title: json['title'] as String?, 34 | url: json['url'] as String?, 35 | author: json['author'] as String?, 36 | points: json['points'] as int?, 37 | storyText: json['story_text'] as String?, 38 | commentText: json['comment_text'] as String?, 39 | numComments: json['num_comments'] as int?, 40 | parentId: json['parent_id'] as int?, 41 | createdAtI: json['created_at_i'] as int?, 42 | ); 43 | 44 | final String objectId; 45 | final String? title; 46 | final String? url; 47 | final String? author; 48 | final int? points; 49 | final String? storyText; 50 | final String? commentText; 51 | final int? numComments; 52 | final int? parentId; 53 | final int? createdAtI; 54 | } 55 | -------------------------------------------------------------------------------- /lib/inbox/cubit/inbox_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 3 | import 'package:glider/common/mixins/data_mixin.dart'; 4 | import 'package:glider/common/models/status.dart'; 5 | import 'package:glider_domain/glider_domain.dart'; 6 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 7 | 8 | part 'inbox_state.dart'; 9 | 10 | class InboxCubit extends HydratedCubit { 11 | InboxCubit(this._itemRepository, this._authRepository) 12 | : super(const InboxState()); 13 | 14 | final ItemRepository _itemRepository; 15 | final AuthRepository _authRepository; 16 | 17 | Future load() async { 18 | safeEmit( 19 | state.copyWith(status: () => Status.loading), 20 | ); 21 | 22 | try { 23 | final (username, _) = await _authRepository.getUserAuth(); 24 | final items = await _itemRepository.getUserReplies(username!); 25 | safeEmit( 26 | state.copyWith( 27 | status: () => Status.success, 28 | data: () => items 29 | .where((e) => e.parentId != null) 30 | .map((e) => (e.parentId!, e.id)) 31 | .toList(growable: false), 32 | exception: () => null, 33 | ), 34 | ); 35 | } on Object catch (exception) { 36 | safeEmit( 37 | state.copyWith( 38 | status: () => Status.failure, 39 | exception: () => exception, 40 | ), 41 | ); 42 | } 43 | } 44 | 45 | @override 46 | InboxState? fromJson(Map json) => InboxState.fromMap(json); 47 | 48 | @override 49 | Map? toJson(InboxState state) => 50 | state.status == Status.success ? state.toMap() : null; 51 | } 52 | -------------------------------------------------------------------------------- /lib/stories_search/models/search_range.dart: -------------------------------------------------------------------------------- 1 | import 'package:clock/clock.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | enum SearchRange { 6 | custom, 7 | pastDay, 8 | past3Days, 9 | pastWeek, 10 | pastMonth, 11 | pastYear; 12 | 13 | String label(BuildContext context, {DateTimeRange? dateRange}) { 14 | return switch (this) { 15 | SearchRange.custom => dateRange != null 16 | ? dateRange.duration != Duration.zero 17 | ? context.l10n.dateRangeCustomMulti( 18 | dateRange.start, 19 | dateRange.end, 20 | ) 21 | : context.l10n.dateRangeCustomSingle(dateRange.start) 22 | : context.l10n.dateRangeCustom, 23 | SearchRange.pastDay => context.l10n.dateRangePastDay, 24 | SearchRange.past3Days => context.l10n.dateRangePast3Days, 25 | SearchRange.pastWeek => context.l10n.dateRangePastWeek, 26 | SearchRange.pastMonth => context.l10n.dateRangePastMonth, 27 | SearchRange.pastYear => context.l10n.dateRangePastYear, 28 | }; 29 | } 30 | 31 | DateTimeRange? dateRange() { 32 | DateTimeRange pastDuration(Duration duration) { 33 | final now = clock.now(); 34 | return DateTimeRange(start: now.subtract(duration), end: now); 35 | } 36 | 37 | DateTimeRange pastDays(int days) => pastDuration(Duration(days: days)); 38 | 39 | return switch (this) { 40 | SearchRange.custom => null, 41 | SearchRange.pastDay => pastDays(1), 42 | SearchRange.past3Days => pastDays(3), 43 | SearchRange.pastWeek => pastDays(7), 44 | SearchRange.pastMonth => pastDays(30), 45 | SearchRange.pastYear => pastDays(365), 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/common/widgets/animated_visibility.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_animation.dart'; 3 | 4 | class AnimatedVisibility extends StatelessWidget { 5 | const AnimatedVisibility({ 6 | super.key, 7 | required this.visible, 8 | this.padding = EdgeInsets.zero, 9 | this.alignment = -1, 10 | required this.child, 11 | }) : _axis = Axis.horizontal, 12 | _replacement = const SizedBox.shrink(); 13 | 14 | const AnimatedVisibility.vertical({ 15 | super.key, 16 | required this.visible, 17 | this.padding = EdgeInsets.zero, 18 | this.alignment = -1, 19 | required this.child, 20 | }) : _axis = Axis.vertical, 21 | _replacement = const SizedBox(width: double.infinity); 22 | 23 | final bool visible; 24 | final EdgeInsetsGeometry padding; 25 | final double alignment; 26 | final Widget child; 27 | final Axis _axis; 28 | final Widget _replacement; 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return AnimatedSwitcher( 33 | duration: AppAnimation.standard.duration, 34 | switchInCurve: AppAnimation.standard.easing, 35 | switchOutCurve: AppAnimation.standard.easing, 36 | transitionBuilder: (child, animation) => SizeTransition( 37 | axis: _axis, 38 | axisAlignment: alignment, 39 | sizeFactor: animation, 40 | child: FadeTransition( 41 | opacity: animation, 42 | child: child, 43 | ), 44 | ), 45 | child: visible 46 | ? AnimatedPadding( 47 | padding: padding, 48 | duration: AppAnimation.standard.duration, 49 | curve: AppAnimation.standard.easing, 50 | child: child, 51 | ) 52 | : _replacement, 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Glider 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Glider 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | CADisableMinimumFrameDurationOnPhone 45 | 46 | UIApplicationSupportsIndirectInputEvents 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /lib/app/bootstrap/bootstrap.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:developer'; 3 | import 'dart:io'; 4 | 5 | import 'package:device_info_plus/device_info_plus.dart'; 6 | import 'package:flutter/foundation.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:flutter/services.dart'; 9 | import 'package:flutter_displaymode/flutter_displaymode.dart'; 10 | import 'package:glider/app/bootstrap/app_bloc_observer.dart'; 11 | import 'package:glider/app/container/app_container.dart'; 12 | import 'package:glider/app/router/app_router.dart'; 13 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 14 | import 'package:path_provider/path_provider.dart'; 15 | 16 | Future bootstrap( 17 | FutureOr Function(AppContainer, AppRouter, BaseDeviceInfo) builder, 18 | ) async { 19 | await runZonedGuarded( 20 | () async { 21 | FlutterError.onError = (details) => 22 | log(details.exceptionAsString(), stackTrace: details.stack); 23 | 24 | WidgetsFlutterBinding.ensureInitialized(); 25 | await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); 26 | if (Platform.isAndroid) await FlutterDisplayMode.setHighRefreshRate(); 27 | 28 | Bloc.observer = const AppBlocObserver(); 29 | HydratedBloc.storage = await HydratedStorage.build( 30 | storageDirectory: kIsWeb 31 | ? HydratedStorage.webStorageDirectory 32 | : await getApplicationCacheDirectory(), 33 | ); 34 | final deviceInfo = await DeviceInfoPlugin().deviceInfo; 35 | 36 | final appContainer = await AppContainer.create(); 37 | unawaited(appContainer.authCubit.init()); 38 | final appRouter = AppRouter.create(appContainer); 39 | 40 | runApp(await builder(appContainer, appRouter, deviceInfo)); 41 | }, 42 | (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/entities/user.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:glider_data/glider_data.dart'; 3 | import 'package:glider_domain/src/extensions/string_extension.dart'; 4 | 5 | class User with EquatableMixin { 6 | const User({ 7 | required this.username, 8 | required this.createdDateTime, 9 | required this.karma, 10 | this.about, 11 | this.submittedIds, 12 | }); 13 | 14 | factory User.fromDto(UserDto dto) => User( 15 | username: dto.id, 16 | createdDateTime: 17 | DateTime.fromMillisecondsSinceEpoch(dto.created * 1000), 18 | karma: dto.karma, 19 | about: dto.about?.convertHtmlToHackerNews(), 20 | submittedIds: dto.submitted ?? const [], 21 | ); 22 | 23 | factory User.fromMap(Map json) => User( 24 | username: json['username'] as String, 25 | createdDateTime: 26 | DateTime.fromMillisecondsSinceEpoch(json['createdDateTime'] as int), 27 | karma: json['karma'] as int, 28 | about: json['about'] as String?, 29 | submittedIds: (json['submittedIds'] as List?) 30 | ?.map((e) => e as int) 31 | .toList(growable: false), 32 | ); 33 | 34 | Map toMap() => { 35 | 'username': username, 36 | 'createdDateTime': createdDateTime.millisecondsSinceEpoch, 37 | 'karma': karma, 38 | 'about': about, 39 | 'submittedIds': submittedIds, 40 | }; 41 | 42 | final String username; 43 | final DateTime createdDateTime; 44 | final int karma; 45 | final String? about; 46 | final List? submittedIds; 47 | 48 | @override 49 | List get props => [ 50 | username, 51 | createdDateTime, 52 | karma, 53 | about, 54 | submittedIds, 55 | ]; 56 | } 57 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "version": 1, 4 | "author": "xcode" 5 | }, 6 | "images": [ 7 | { 8 | "size": "16x16", 9 | "idiom": "mac", 10 | "filename": "app_icon_16.png", 11 | "scale": "1x" 12 | }, 13 | { 14 | "size": "16x16", 15 | "idiom": "mac", 16 | "filename": "app_icon_32.png", 17 | "scale": "2x" 18 | }, 19 | { 20 | "size": "32x32", 21 | "idiom": "mac", 22 | "filename": "app_icon_32.png", 23 | "scale": "1x" 24 | }, 25 | { 26 | "size": "32x32", 27 | "idiom": "mac", 28 | "filename": "app_icon_64.png", 29 | "scale": "2x" 30 | }, 31 | { 32 | "size": "128x128", 33 | "idiom": "mac", 34 | "filename": "app_icon_128.png", 35 | "scale": "1x" 36 | }, 37 | { 38 | "size": "128x128", 39 | "idiom": "mac", 40 | "filename": "app_icon_256.png", 41 | "scale": "2x" 42 | }, 43 | { 44 | "size": "256x256", 45 | "idiom": "mac", 46 | "filename": "app_icon_256.png", 47 | "scale": "1x" 48 | }, 49 | { 50 | "size": "256x256", 51 | "idiom": "mac", 52 | "filename": "app_icon_512.png", 53 | "scale": "2x" 54 | }, 55 | { 56 | "size": "512x512", 57 | "idiom": "mac", 58 | "filename": "app_icon_512.png", 59 | "scale": "1x" 60 | }, 61 | { 62 | "size": "512x512", 63 | "idiom": "mac", 64 | "filename": "app_icon_1024.png", 65 | "scale": "2x" 66 | } 67 | ] 68 | } -------------------------------------------------------------------------------- /lib/auth/cubit/auth_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:flutter_inappwebview/flutter_inappwebview.dart'; 4 | import 'package:glider/common/constants/app_uris.dart'; 5 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 6 | import 'package:glider_domain/glider_domain.dart'; 7 | 8 | part 'auth_state.dart'; 9 | 10 | class AuthCubit extends Cubit { 11 | AuthCubit( 12 | this._authRepository, 13 | this._itemInteractionRepository, 14 | this._cookieManager, 15 | ) : super(const AuthState()); 16 | 17 | final AuthRepository _authRepository; 18 | final ItemInteractionRepository _itemInteractionRepository; 19 | final CookieManager _cookieManager; 20 | 21 | Future init() async { 22 | await _updateLoggedIn(); 23 | } 24 | 25 | Future login() async { 26 | final userCookieUrl = WebUri.uri(AppUris.hackerNewsUri); 27 | const userCookieName = 'user'; 28 | final userCookie = await _cookieManager.getCookie( 29 | url: userCookieUrl, 30 | name: userCookieName, 31 | ); 32 | 33 | if (userCookie case Cookie(:final String value)) { 34 | await _authRepository.login(value); 35 | await _cookieManager.deleteAllCookies(); 36 | await _updateLoggedIn(); 37 | } 38 | } 39 | 40 | Future logout() async { 41 | await _authRepository.logout(); 42 | await _itemInteractionRepository.getUpvotedIds(); 43 | await _itemInteractionRepository.getFavoritedIds(); 44 | await _itemInteractionRepository.getFlaggedIds(); 45 | await _updateLoggedIn(); 46 | } 47 | 48 | Future _updateLoggedIn() async { 49 | final (username, userCookie) = await _authRepository.getUserAuth(); 50 | safeEmit( 51 | state.copyWith( 52 | isLoggedIn: () => userCookie != null, 53 | username: () => username, 54 | ), 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/favorites/cubit/favorites_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 5 | import 'package:glider/common/mixins/data_mixin.dart'; 6 | import 'package:glider/common/models/status.dart'; 7 | import 'package:glider_domain/glider_domain.dart'; 8 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 9 | 10 | part 'favorites_state.dart'; 11 | 12 | class FavoritesCubit extends HydratedCubit { 13 | FavoritesCubit(this._itemInteractionRepository) 14 | : super(const FavoritesState()) { 15 | _favoriteIdsSubscription = 16 | _itemInteractionRepository.favoritedStream.listen( 17 | (itemIds) => safeEmit( 18 | state.copyWith( 19 | status: () => Status.success, 20 | data: () => itemIds, 21 | exception: () => null, 22 | ), 23 | ), 24 | // ignore: avoid_types_on_closure_parameters 25 | onError: (Object exception) => safeEmit( 26 | state.copyWith( 27 | status: () => Status.failure, 28 | exception: () => exception, 29 | ), 30 | ), 31 | ); 32 | } 33 | 34 | final ItemInteractionRepository _itemInteractionRepository; 35 | 36 | late final StreamSubscription> _favoriteIdsSubscription; 37 | 38 | Future load() async { 39 | safeEmit( 40 | state.copyWith(status: () => Status.loading), 41 | ); 42 | await _itemInteractionRepository.getFavoritedIds(); 43 | } 44 | 45 | @override 46 | FavoritesState? fromJson(Map json) => 47 | FavoritesState.fromMap(json); 48 | 49 | @override 50 | Map? toJson(FavoritesState state) => 51 | state.status == Status.success ? state.toMap() : null; 52 | 53 | @override 54 | Future close() async { 55 | await _favoriteIdsSubscription.cancel(); 56 | return super.close(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/user/cubit/user_state.dart: -------------------------------------------------------------------------------- 1 | part of 'user_cubit.dart'; 2 | 3 | class UserState with DataMixin, EquatableMixin { 4 | const UserState({ 5 | this.status = Status.initial, 6 | this.data, 7 | this.parsedAbout, 8 | this.blocked = false, 9 | this.synchronizing = false, 10 | this.exception, 11 | }); 12 | 13 | factory UserState.fromMap(Map json) => UserState( 14 | status: Status.values.byName(json['status'] as String), 15 | data: User.fromMap(json['data'] as Map), 16 | blocked: json['blocked'] as bool? ?? false, 17 | ); 18 | 19 | Map toMap() => { 20 | 'status': status.name, 21 | 'data': data?.toMap(), 22 | 'blocked': blocked, 23 | }; 24 | 25 | @override 26 | final Status status; 27 | @override 28 | final User? data; 29 | final ParsedData? parsedAbout; 30 | final bool blocked; 31 | final bool synchronizing; 32 | @override 33 | final Object? exception; 34 | 35 | UserState copyWith({ 36 | Status Function()? status, 37 | User? Function()? data, 38 | ParsedData? Function()? parsedAbout, 39 | bool Function()? blocked, 40 | bool Function()? synchronizing, 41 | Object? Function()? exception, 42 | }) => 43 | UserState( 44 | status: status != null ? status() : this.status, 45 | data: data != null ? data() : this.data, 46 | parsedAbout: parsedAbout != null ? parsedAbout() : this.parsedAbout, 47 | blocked: blocked != null ? blocked() : this.blocked, 48 | synchronizing: 49 | synchronizing != null ? synchronizing() : this.synchronizing, 50 | exception: exception != null ? exception() : this.exception, 51 | ); 52 | 53 | @override 54 | List get props => [ 55 | status, 56 | data, 57 | parsedAbout, 58 | blocked, 59 | synchronizing, 60 | exception, 61 | ]; 62 | } 63 | -------------------------------------------------------------------------------- /lib/user/models/user_value.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/auth/cubit/auth_cubit.dart'; 3 | import 'package:glider/common/constants/app_uris.dart'; 4 | import 'package:glider/common/interfaces/menu_item.dart'; 5 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 6 | import 'package:glider/settings/cubit/settings_cubit.dart'; 7 | import 'package:glider/user/cubit/user_cubit.dart'; 8 | 9 | enum UserValue implements MenuItem { 10 | username, 11 | about, 12 | userLink; 13 | 14 | @override 15 | bool isVisible( 16 | UserState state, 17 | AuthState authState, 18 | SettingsState settingsState, 19 | ) { 20 | final user = state.data; 21 | if (user == null) return false; 22 | return switch (this) { 23 | UserValue.username => true, 24 | UserValue.about => user.about != null, 25 | UserValue.userLink => true, 26 | }; 27 | } 28 | 29 | @override 30 | String label(BuildContext context, UserState state) { 31 | return switch (this) { 32 | UserValue.username => context.l10n.username, 33 | UserValue.about => context.l10n.about, 34 | UserValue.userLink => context.l10n.userLink, 35 | }; 36 | } 37 | 38 | @override 39 | IconData icon(UserState state) { 40 | return switch (this) { 41 | UserValue.username => Icons.person_outline_outlined, 42 | UserValue.about => Icons.notes_outlined, 43 | UserValue.userLink => Icons.account_circle_outlined, 44 | }; 45 | } 46 | 47 | String? value(UserCubit userCubit) { 48 | final user = userCubit.state.data; 49 | return switch (this) { 50 | UserValue.username => userCubit.username, 51 | UserValue.about => user?.about, 52 | UserValue.userLink => AppUris.hackerNewsUri.replace( 53 | path: 'user', 54 | queryParameters: { 55 | 'id': userCubit.username, 56 | }, 57 | ).toString(), 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/glider_domain/lib/src/extensions/string_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:html/dom.dart' as html_dom; 2 | import 'package:html/parser.dart' as html_parser; 3 | 4 | // https://news.ycombinator.com/formatdoc 5 | extension StringExtension on String { 6 | String convertHtmlToHackerNews() => html_parser.parseFragment(this).convert(); 7 | } 8 | 9 | extension on html_dom.Node { 10 | String get _url => attributes['href'] ?? text!; 11 | 12 | String convert() => switch (this) { 13 | // "Urls become links, except in the text field of a submission." 14 | // We cheat by not handling submissions any differently. 15 | // Unlike the website, we prefer showing the full URL. 16 | html_dom.Element(localName: 'a') => '[$_url](${Uri.decodeFull(_url)})', 17 | // "Text surrounded by asterisks is italicized." 18 | html_dom.Element(localName: 'i') => '*${convertNodes()}*', 19 | // "Blank lines separate paragraphs." 20 | html_dom.Element(localName: 'p') => '\n\n${convertNodes()}', 21 | // "Text after a blank line that is indented by two or more spaces is 22 | // reproduced verbatim. (This is intended for code.)" 23 | // No need to add spaces here though, as they're part of the HTML. 24 | html_dom.Text(parentNode: html_dom.Element(localName: 'code')) => text!, 25 | // "To get a literal asterisk, use \* or **." 26 | // Escape asterisks not surrounded by spaces. 27 | // There may be newlines part of the HTML which don't show up on the 28 | // website. Replace them with a space, but exclude starting newlines. 29 | // There may be adjacent spaces. Replace them with single spaces. 30 | html_dom.Text() => text! 31 | .replaceAll(RegExp(r'(? convertNodes(), 35 | }; 36 | 37 | String convertNodes() => nodes.map((node) => node.convert()).join(); 38 | } 39 | -------------------------------------------------------------------------------- /lib/edit/cubit/edit_state.dart: -------------------------------------------------------------------------------- 1 | part of 'edit_cubit.dart'; 2 | 3 | class EditState with EquatableMixin { 4 | const EditState({ 5 | this.item, 6 | this.title, 7 | this.text, 8 | this.isValid = false, 9 | this.preview = true, 10 | this.success = false, 11 | }); 12 | 13 | factory EditState.fromMap(Map json) => EditState( 14 | item: json['item'] != null 15 | ? Item.fromMap(json['item'] as Map) 16 | : null, 17 | title: json['title'] != null 18 | ? TitleInput.pure(json['title'] as String) 19 | : null, 20 | text: json['text'] != null 21 | ? TextInput.pure(json['text'] as String) 22 | : null, 23 | isValid: json['isValid'] as bool? ?? false, 24 | ); 25 | 26 | Map toMap() => { 27 | 'item': item?.toMap(), 28 | 'title': title?.value, 29 | 'text': text?.value, 30 | 'isValid': isValid, 31 | }; 32 | 33 | final Item? item; 34 | final TitleInput? title; 35 | final TextInput? text; 36 | final bool isValid; 37 | final bool preview; 38 | final bool success; 39 | 40 | EditState copyWith({ 41 | Item Function()? item, 42 | TitleInput? Function()? title, 43 | TextInput? Function()? text, 44 | bool Function()? isValid, 45 | bool Function()? preview, 46 | bool Function()? success, 47 | }) => 48 | EditState( 49 | item: item != null ? item() : this.item, 50 | title: title != null ? title() : this.title, 51 | text: text != null ? text() : this.text, 52 | isValid: isValid != null ? isValid() : this.isValid, 53 | preview: preview != null ? preview() : this.preview, 54 | success: success != null ? success() : this.success, 55 | ); 56 | 57 | @override 58 | List get props => [ 59 | item, 60 | title, 61 | text, 62 | isValid, 63 | preview, 64 | success, 65 | ]; 66 | } 67 | -------------------------------------------------------------------------------- /.github/actions/bootstrap/action.yml: -------------------------------------------------------------------------------- 1 | name: Bootstrap 2 | description: Bootstrap workspace 3 | inputs: 4 | fvm-version: 5 | description: FVM version 6 | required: false 7 | default: 3.1.3 8 | runs: 9 | using: composite 10 | steps: 11 | - name: Set up Homebrew 12 | uses: Homebrew/actions/setup-homebrew@master 13 | - name: Disable Homebrew analytics 14 | shell: bash 15 | run: brew analytics off 16 | - name: Get Homebrew directory 17 | shell: bash 18 | run: echo "HOMEBREW_CELLAR=$(brew --cellar)" >> $GITHUB_ENV 19 | - name: Cache Homebrew 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | ${{ env.HOMEBREW_CELLAR }}/dart 24 | ${{ env.HOMEBREW_CELLAR }}/fvm@${{ inputs.fvm-version }} 25 | key: ${{ runner.os }}-homebrew-fvm-${{ inputs.fvm-version }} 26 | restore-keys: ${{ runner.os }}-homebrew-fvm- 27 | - name: Cache FVM 28 | uses: actions/cache@v3 29 | with: 30 | path: ~/fvm 31 | key: ${{ runner.os }}-fvm-${{ hashFiles('.fvmrc') }} 32 | restore-keys: ${{ runner.os }}-fvm- 33 | - name: Cache pub 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.pub-cache 37 | key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} 38 | restore-keys: ${{ runner.os }}-pub- 39 | - name: Set up FVM 40 | shell: bash 41 | run: | 42 | brew tap leoafarias/fvm 43 | brew link dart fvm@${{ inputs.fvm-version }} || brew install fvm@${{ inputs.fvm-version }} 44 | env: 45 | HOMEBREW_NO_ENV_HINTS: '1' 46 | - name: Set up Flutter 47 | shell: bash 48 | run: | 49 | fvm install 50 | fvm dart --disable-analytics 51 | fvm flutter config --disable-analytics 52 | echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH 53 | - name: Set up Melos 54 | shell: bash 55 | run: fvm dart pub global activate melos 56 | - name: Bootstrap workspace 57 | shell: bash 58 | run: fvm flutter pub global run melos bootstrap 59 | -------------------------------------------------------------------------------- /lib/stories/cubit/stories_state.dart: -------------------------------------------------------------------------------- 1 | part of 'stories_cubit.dart'; 2 | 3 | class StoriesState 4 | with DataMixin>, PaginatedListMixin, EquatableMixin { 5 | StoriesState({ 6 | this.status = Status.initial, 7 | this.data, 8 | this.page = 1, 9 | this.storyType = StoryType.topStories, 10 | this.exception, 11 | }); 12 | 13 | factory StoriesState.fromMap(Map json) => StoriesState( 14 | status: Status.values.byName(json['status'] as String), 15 | data: (json['data'] as List?) 16 | ?.map((e) => e as int) 17 | .toList(growable: false), 18 | storyType: StoryType.values.byName(json['storyType'] as String), 19 | ); 20 | 21 | Map toMap() => { 22 | 'status': status.name, 23 | 'data': data, 24 | 'storyType': storyType.name, 25 | }; 26 | 27 | @override 28 | final Status status; 29 | @override 30 | final List? data; 31 | @override 32 | final int page; 33 | final StoryType storyType; 34 | @override 35 | final Object? exception; 36 | 37 | @override 38 | late List? loadedData = super.loadedData?.toList(growable: false); 39 | 40 | @override 41 | late List? currentPageData = 42 | super.currentPageData?.toList(growable: false); 43 | 44 | StoriesState copyWith({ 45 | Status Function()? status, 46 | List? Function()? data, 47 | int Function()? page, 48 | StoryType Function()? storyType, 49 | Object? Function()? exception, 50 | }) => 51 | StoriesState( 52 | status: status != null ? status() : this.status, 53 | data: data != null ? data() : this.data, 54 | page: page != null ? page() : this.page, 55 | storyType: storyType != null ? storyType() : this.storyType, 56 | exception: exception != null ? exception() : this.exception, 57 | ); 58 | 59 | @override 60 | List get props => [ 61 | status, 62 | data, 63 | page, 64 | storyType, 65 | exception, 66 | ]; 67 | } 68 | -------------------------------------------------------------------------------- /lib/submit/cubit/submit_state.dart: -------------------------------------------------------------------------------- 1 | part of 'submit_cubit.dart'; 2 | 3 | class SubmitState with EquatableMixin { 4 | const SubmitState({ 5 | this.title = const TitleInput.pure(''), 6 | this.url = const UrlInput.pure(''), 7 | this.text = const TextInput.pure(''), 8 | this.isValid = false, 9 | this.preview = true, 10 | this.success = false, 11 | }); 12 | 13 | factory SubmitState.fromMap(Map json) => SubmitState( 14 | title: TitleInput.pure(json['title'] as String? ?? ''), 15 | url: UrlInput.pure( 16 | json['url'] as String? ?? '', 17 | text: json['text'] as String? ?? '', 18 | ), 19 | text: TextInput.pure( 20 | json['text'] as String? ?? '', 21 | url: json['url'] as String? ?? '', 22 | ), 23 | isValid: json['isValid'] as bool? ?? false, 24 | ); 25 | 26 | Map toMap() => { 27 | 'title': title.value, 28 | 'url': url.value, 29 | 'text': text.value, 30 | 'isValid': isValid, 31 | }; 32 | 33 | final TitleInput title; 34 | final UrlInput url; 35 | final TextInput text; 36 | final bool isValid; 37 | final bool preview; 38 | final bool success; 39 | 40 | SubmitState copyWith({ 41 | TitleInput Function()? title, 42 | UrlInput Function()? url, 43 | TextInput Function()? text, 44 | bool Function()? isValid, 45 | bool Function()? preview, 46 | bool Function()? success, 47 | }) => 48 | SubmitState( 49 | title: title != null ? title() : this.title, 50 | url: url != null ? url() : this.url, 51 | text: text != null ? text() : this.text, 52 | isValid: isValid != null ? isValid() : this.isValid, 53 | preview: preview != null ? preview() : this.preview, 54 | success: success != null ? success() : this.success, 55 | ); 56 | 57 | @override 58 | List get props => [ 59 | title, 60 | url, 61 | text, 62 | isValid, 63 | preview, 64 | success, 65 | ]; 66 | } 67 | -------------------------------------------------------------------------------- /lib/user_item_search/bloc/user_item_search_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 5 | import 'package:glider/common/mixins/data_mixin.dart'; 6 | import 'package:glider/common/models/status.dart'; 7 | import 'package:glider/common/transformers/debounce.dart'; 8 | import 'package:glider_domain/glider_domain.dart'; 9 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 10 | 11 | part 'user_item_search_event.dart'; 12 | part 'user_item_search_state.dart'; 13 | 14 | class UserItemSearchBloc 15 | extends Bloc { 16 | UserItemSearchBloc(this._itemRepository, {required this.username}) 17 | : super(const UserItemSearchState()) { 18 | on( 19 | (event, emit) async => _load(), 20 | transformer: debounce(const Duration(milliseconds: 300)), 21 | ); 22 | on( 23 | (event, emit) async => _setText(event), 24 | ); 25 | } 26 | 27 | final ItemRepository _itemRepository; 28 | final String username; 29 | 30 | Future _load() async { 31 | safeEmit( 32 | state.copyWith(status: () => Status.loading), 33 | ); 34 | 35 | try { 36 | final items = await _itemRepository.searchUserItems( 37 | username, 38 | text: state.searchText, 39 | ); 40 | safeEmit( 41 | state.copyWith( 42 | status: () => Status.success, 43 | data: () => items.map((item) => item.id).toList(growable: false), 44 | exception: () => null, 45 | ), 46 | ); 47 | } on Object catch (exception) { 48 | safeEmit( 49 | state.copyWith( 50 | status: () => Status.failure, 51 | exception: () => exception, 52 | ), 53 | ); 54 | } 55 | } 56 | 57 | Future _setText(SetTextUserItemSearchEvent event) async { 58 | safeEmit( 59 | state.copyWith(searchText: () => event.text), 60 | ); 61 | add(const LoadUserItemSearchEvent()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/glider_data/lib/src/hacker_news_api_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:compute/compute.dart'; 4 | import 'package:glider_data/src/dtos/item_dto.dart'; 5 | import 'package:glider_data/src/dtos/user_dto.dart'; 6 | import 'package:http/http.dart' as http; 7 | 8 | class HackerNewsApiService { 9 | const HackerNewsApiService(this._client); 10 | 11 | final http.Client _client; 12 | 13 | static const authority = 'hacker-news.firebaseio.com'; 14 | 15 | Future> getTopStoryIds() async => _getIds('v0/topstories.json'); 16 | 17 | Future> getNewStoryIds() async => _getIds('v0/newstories.json'); 18 | 19 | Future> getBestStoryIds() async => _getIds('v0/beststories.json'); 20 | 21 | Future> getAskStoryIds() async => _getIds('v0/askstories.json'); 22 | 23 | Future> getShowStoryIds() async => _getIds('v0/showstories.json'); 24 | 25 | Future> getJobStoryIds() async => _getIds('v0/jobstories.json'); 26 | 27 | Future> _getIds(String path) async { 28 | final response = await _client.get(Uri.https(authority, path)); 29 | return compute( 30 | (body) { 31 | final list = jsonDecode(body) as List; 32 | return list.map((e) => e as int).toList(growable: false); 33 | }, 34 | response.body, 35 | ); 36 | } 37 | 38 | Future getItem(int id) async { 39 | final path = 'v0/item/$id.json'; 40 | final response = await _client.get(Uri.https(authority, path)); 41 | return compute( 42 | (body) { 43 | final map = jsonDecode(body) as Map; 44 | return ItemDto.fromMap(map); 45 | }, 46 | response.body, 47 | ); 48 | } 49 | 50 | Future getUser(String id) async { 51 | final path = 'v0/user/$id.json'; 52 | final response = await _client.get(Uri.https(authority, path)); 53 | return compute( 54 | (body) { 55 | final map = jsonDecode(body) as Map; 56 | return UserDto.fromMap(map); 57 | }, 58 | response.body, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/story_item_search/bloc/story_item_search_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 5 | import 'package:glider/common/mixins/data_mixin.dart'; 6 | import 'package:glider/common/models/status.dart'; 7 | import 'package:glider/common/transformers/debounce.dart'; 8 | import 'package:glider_domain/glider_domain.dart'; 9 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 10 | 11 | part 'story_item_search_event.dart'; 12 | part 'story_item_search_state.dart'; 13 | 14 | class StoryItemSearchBloc 15 | extends Bloc { 16 | StoryItemSearchBloc(this._itemRepository, {required int id}) 17 | : itemId = id, 18 | super(const StoryItemSearchState()) { 19 | on( 20 | (event, emit) async => _load(), 21 | transformer: debounce(const Duration(milliseconds: 300)), 22 | ); 23 | on( 24 | (event, emit) async => _setText(event), 25 | ); 26 | } 27 | 28 | final ItemRepository _itemRepository; 29 | final int itemId; 30 | 31 | Future _load() async { 32 | safeEmit( 33 | state.copyWith(status: () => Status.loading), 34 | ); 35 | 36 | try { 37 | final items = await _itemRepository.searchStoryItems( 38 | itemId, 39 | text: state.searchText, 40 | ); 41 | safeEmit( 42 | state.copyWith( 43 | status: () => Status.success, 44 | data: () => items.map((item) => item.id).toList(growable: false), 45 | exception: () => null, 46 | ), 47 | ); 48 | } on Object catch (exception) { 49 | safeEmit( 50 | state.copyWith( 51 | status: () => Status.failure, 52 | exception: () => exception, 53 | ), 54 | ); 55 | } 56 | } 57 | 58 | Future _setText(SetTextStoryItemSearchEvent event) async { 59 | safeEmit( 60 | state.copyWith(searchText: () => event.text), 61 | ); 62 | add(const LoadStoryItemSearchEvent()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/user/widgets/user_bottom_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:glider/auth/cubit/auth_cubit.dart'; 4 | import 'package:glider/settings/cubit/settings_cubit.dart'; 5 | import 'package:glider/user/cubit/user_cubit.dart'; 6 | import 'package:glider/user/models/user_action.dart'; 7 | import 'package:go_router/go_router.dart'; 8 | 9 | class UserBottomSheet extends StatelessWidget { 10 | const UserBottomSheet( 11 | this._userCubit, 12 | this._authCubit, 13 | this._settingsCubit, { 14 | super.key, 15 | }); 16 | 17 | final UserCubit _userCubit; 18 | final AuthCubit _authCubit; 19 | final SettingsCubit _settingsCubit; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return BlocBuilder( 24 | bloc: _userCubit, 25 | builder: (context, state) => BlocBuilder( 26 | bloc: _authCubit, 27 | builder: (context, authState) => 28 | BlocBuilder( 29 | bloc: _settingsCubit, 30 | builder: (context, settingsState) => ListView( 31 | primary: false, 32 | shrinkWrap: true, 33 | children: [ 34 | for (final action in UserAction.values) 35 | if (action.isVisible(state, authState, settingsState)) 36 | ListTile( 37 | leading: Icon(action.icon(state)), 38 | title: Text(action.label(context, state)), 39 | trailing: action.options != null 40 | ? const Icon(Icons.chevron_right) 41 | : null, 42 | onTap: () async { 43 | context.pop(); 44 | await action.execute( 45 | context, 46 | _userCubit, 47 | _authCubit, 48 | ); 49 | }, 50 | ), 51 | ], 52 | ), 53 | ), 54 | ), 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/common/widgets/failure_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_spacing.dart'; 3 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 4 | 5 | class FailureWidget extends StatelessWidget { 6 | const FailureWidget({ 7 | super.key, 8 | this.title, 9 | this.exception, 10 | this.onRetry, 11 | this.compact = false, 12 | }); 13 | 14 | final String? title; 15 | final Object? exception; 16 | final VoidCallback? onRetry; 17 | final bool compact; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return Center( 22 | child: Column( 23 | mainAxisSize: MainAxisSize.min, 24 | children: [ 25 | ListTile( 26 | title: Text(title ?? context.l10n.failure), 27 | subtitle: exception != null 28 | ? Text(exception.runtimeType.toString()) 29 | : null, 30 | trailing: compact && onRetry != null 31 | ? IconButton.outlined( 32 | onPressed: onRetry, 33 | style: IconButton.styleFrom( 34 | tapTargetSize: MaterialTapTargetSize.shrinkWrap, 35 | ), 36 | icon: const Icon(Icons.refresh_outlined), 37 | ) 38 | : null, 39 | textColor: Theme.of(context).colorScheme.error, 40 | ), 41 | if (!compact && onRetry != null) 42 | Padding( 43 | padding: AppSpacing.defaultTilePadding, 44 | child: SizedBox( 45 | width: double.infinity, 46 | child: OutlinedButton.icon( 47 | onPressed: onRetry, 48 | style: OutlinedButton.styleFrom( 49 | tapTargetSize: MaterialTapTargetSize.shrinkWrap, 50 | ), 51 | label: Text(context.l10n.retry), 52 | icon: const Icon(Icons.refresh_outlined), 53 | ), 54 | ), 55 | ), 56 | ], 57 | ), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/stories/view/stories_type_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:glider/common/constants/app_spacing.dart'; 4 | import 'package:glider/common/extensions/widget_list_extension.dart'; 5 | import 'package:glider/settings/cubit/settings_cubit.dart'; 6 | import 'package:glider/stories/cubit/stories_cubit.dart'; 7 | import 'package:glider/stories/models/story_type.dart'; 8 | 9 | class StoriesTypeView extends StatelessWidget { 10 | const StoriesTypeView( 11 | this._storiesCubit, 12 | this._settingsCubit, { 13 | super.key, 14 | }); 15 | 16 | final StoriesCubit _storiesCubit; 17 | final SettingsCubit _settingsCubit; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return BlocBuilder( 22 | bloc: _storiesCubit, 23 | builder: (context, state) => BlocBuilder( 24 | bloc: _settingsCubit, 25 | builder: (context, settingsState) => MenuAnchor( 26 | menuChildren: [ 27 | for (final storyType in StoryType.values) 28 | if (storyType != StoryType.jobStories || settingsState.showJobs) 29 | MenuItemButton( 30 | onPressed: () async => _storiesCubit.setStoryType(storyType), 31 | child: Text(storyType.label(context)), 32 | ), 33 | ], 34 | builder: (context, controller, child) => FilterChip.elevated( 35 | avatar: Icon(state.storyType.icon), 36 | label: Row( 37 | children: [ 38 | Expanded( 39 | child: Text(state.storyType.label(context)), 40 | ), 41 | const Icon(Icons.arrow_drop_down), 42 | ].spaced(width: AppSpacing.m), 43 | ), 44 | labelPadding: const EdgeInsetsDirectional.only(start: AppSpacing.m), 45 | onSelected: (storyType) => 46 | controller.isOpen ? controller.close() : controller.open(), 47 | ), 48 | ), 49 | ), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/stories_search/view/stories_search_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/app/container/app_container.dart'; 3 | import 'package:glider/auth/cubit/auth_cubit.dart'; 4 | import 'package:glider/common/constants/app_spacing.dart'; 5 | import 'package:glider/common/widgets/notification_canceler.dart'; 6 | import 'package:glider/common/widgets/refreshable_scroll_view.dart'; 7 | import 'package:glider/settings/cubit/settings_cubit.dart'; 8 | import 'package:glider/stories_search/bloc/stories_search_bloc.dart'; 9 | import 'package:glider/stories_search/view/sliver_stories_search_body.dart'; 10 | import 'package:glider/stories_search/view/stories_search_range_view.dart'; 11 | 12 | class StoriesSearchView extends StatelessWidget { 13 | const StoriesSearchView( 14 | this._storiesSearchBloc, 15 | this._itemCubitFactory, 16 | this._authCubit, 17 | this._settingsCubit, { 18 | super.key, 19 | }); 20 | 21 | final StoriesSearchBloc _storiesSearchBloc; 22 | final ItemCubitFactory _itemCubitFactory; 23 | final AuthCubit _authCubit; 24 | final SettingsCubit _settingsCubit; 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return NotificationCanceler( 29 | child: RefreshableScrollView( 30 | onRefresh: () async => 31 | _storiesSearchBloc.add(const LoadStoriesSearchEvent()), 32 | edgeOffset: 0, 33 | slivers: [ 34 | SliverPadding( 35 | padding: const EdgeInsets.only(top: AppSpacing.m), 36 | sliver: SliverToBoxAdapter( 37 | child: StoriesSearchRangeView(_storiesSearchBloc), 38 | ), 39 | ), 40 | SliverSafeArea( 41 | top: false, 42 | sliver: SliverStoriesSearchBody( 43 | _storiesSearchBloc, 44 | _itemCubitFactory, 45 | _authCubit, 46 | _settingsCubit, 47 | ), 48 | ), 49 | const SliverPadding( 50 | padding: AppSpacing.floatingActionButtonPageBottomPadding, 51 | ), 52 | ], 53 | ), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/item/models/item_value.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/auth/cubit/auth_cubit.dart'; 3 | import 'package:glider/common/constants/app_uris.dart'; 4 | import 'package:glider/common/interfaces/menu_item.dart'; 5 | import 'package:glider/item/cubit/item_cubit.dart'; 6 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 7 | import 'package:glider/settings/cubit/settings_cubit.dart'; 8 | 9 | enum ItemValue implements MenuItem { 10 | title, 11 | link, 12 | text, 13 | itemLink; 14 | 15 | @override 16 | bool isVisible( 17 | ItemState state, 18 | AuthState authState, 19 | SettingsState settingsState, 20 | ) { 21 | final item = state.data; 22 | if (item == null) return false; 23 | return switch (this) { 24 | ItemValue.title => item.title != null, 25 | ItemValue.link => item.url != null, 26 | ItemValue.text => item.text != null, 27 | ItemValue.itemLink => true, 28 | }; 29 | } 30 | 31 | @override 32 | String label(BuildContext context, ItemState state) { 33 | return switch (this) { 34 | ItemValue.title => context.l10n.title, 35 | ItemValue.link => context.l10n.link, 36 | ItemValue.text => context.l10n.text, 37 | ItemValue.itemLink => context.l10n.itemLink, 38 | }; 39 | } 40 | 41 | @override 42 | IconData icon(ItemState state) { 43 | return switch (this) { 44 | ItemValue.title => Icons.title_outlined, 45 | ItemValue.link => Icons.link_outlined, 46 | ItemValue.text => Icons.notes_outlined, 47 | ItemValue.itemLink => Icons.forum_outlined, 48 | }; 49 | } 50 | 51 | String? value(ItemCubit itemCubit) { 52 | final item = itemCubit.state.data; 53 | return switch (this) { 54 | ItemValue.title => item?.title, 55 | ItemValue.link => item?.url.toString(), 56 | ItemValue.text => item?.text, 57 | ItemValue.itemLink => AppUris.hackerNewsUri.replace( 58 | path: 'item', 59 | queryParameters: { 60 | 'id': itemCubit.itemId.toString(), 61 | }, 62 | ).toString(), 63 | }; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/item/widgets/indented_widget.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:glider/common/constants/app_spacing.dart'; 5 | 6 | class IndentedWidget extends StatelessWidget { 7 | const IndentedWidget({ 8 | super.key, 9 | required this.depth, 10 | required this.child, 11 | }); 12 | 13 | final int depth; 14 | final Widget child; 15 | 16 | static const double _fadedOpacity = 0.25; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | if (depth == 0) { 21 | return child; 22 | } 23 | 24 | final colorScheme = Theme.of(context).colorScheme; 25 | final colors = [ 26 | colorScheme.primary, 27 | colorScheme.secondary, 28 | colorScheme.tertiary, 29 | ]; 30 | 31 | return Stack( 32 | children: [ 33 | Padding( 34 | padding: EdgeInsetsDirectional.only(start: _getIndentation(depth)), 35 | child: child, 36 | ), 37 | for (var i = 1; i < depth; i++) 38 | PositionedDirectional( 39 | start: _getIndentation(i), 40 | top: 0, 41 | bottom: 0, 42 | child: VerticalDivider( 43 | width: 0, 44 | color: Theme.of(context) 45 | .colorScheme 46 | .outline 47 | .withOpacity(_fadedOpacity), 48 | ), 49 | ), 50 | PositionedDirectional( 51 | start: _getIndentation(depth), 52 | top: AppSpacing.s, 53 | bottom: AppSpacing.s, 54 | child: VerticalDivider( 55 | width: 0, 56 | color: _getCurrentColor(colors), 57 | ), 58 | ), 59 | ], 60 | ); 61 | } 62 | 63 | Color _getCurrentColor(List colors) => colors[depth % colors.length]; 64 | 65 | // Discussion threads can get deep to the point where simply indenting with a 66 | // fixed amount for every level can cause UI problems. We combat this by 67 | // reducing additional indentation for higher depths. At a depth of 16, half 68 | // of the indentation gets added compared to depth 1. 69 | double _getIndentation(int i) => AppSpacing.xl * pow(i, 0.75) - 1; 70 | } 71 | -------------------------------------------------------------------------------- /lib/whats_new/view/whats_new_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:glider/common/constants/app_spacing.dart'; 4 | import 'package:glider/common/widgets/hacker_news_text.dart'; 5 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 6 | import 'package:glider/settings/cubit/settings_cubit.dart'; 7 | import 'package:go_router/go_router.dart'; 8 | 9 | class WhatsNewPage extends StatelessWidget { 10 | const WhatsNewPage( 11 | this._settingsCubit, { 12 | super.key, 13 | }); 14 | 15 | final SettingsCubit _settingsCubit; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Scaffold( 20 | body: CustomScrollView( 21 | slivers: [ 22 | const _SliverWhatsNewAppBar(), 23 | SliverSafeArea( 24 | top: false, 25 | sliver: SliverToBoxAdapter( 26 | child: _WhatsNewBody(_settingsCubit), 27 | ), 28 | ), 29 | const SliverPadding( 30 | padding: AppSpacing.floatingActionButtonPageBottomPadding, 31 | ), 32 | ], 33 | ), 34 | floatingActionButton: FloatingActionButton.extended( 35 | onPressed: () => context.pop(), 36 | icon: const Icon(Icons.rocket_launch_outlined), 37 | label: Text(context.l10n.explore), 38 | ), 39 | ); 40 | } 41 | } 42 | 43 | class _SliverWhatsNewAppBar extends StatelessWidget { 44 | const _SliverWhatsNewAppBar(); 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return SliverAppBar.medium(title: Text(context.l10n.whatsNew)); 49 | } 50 | } 51 | 52 | class _WhatsNewBody extends StatelessWidget { 53 | const _WhatsNewBody(this._settingsCubit); 54 | 55 | final SettingsCubit _settingsCubit; 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return BlocBuilder( 60 | bloc: _settingsCubit, 61 | builder: (context, settingsState) => Padding( 62 | padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), 63 | child: HackerNewsText( 64 | context.l10n.whatsNewDescription, 65 | useInAppBrowser: settingsState.useInAppBrowser, 66 | ), 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/item/widgets/item_bottom_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:glider/auth/cubit/auth_cubit.dart'; 4 | import 'package:glider/common/widgets/notification_canceler.dart'; 5 | import 'package:glider/item/cubit/item_cubit.dart'; 6 | import 'package:glider/item/models/item_action.dart'; 7 | import 'package:glider/settings/cubit/settings_cubit.dart'; 8 | import 'package:go_router/go_router.dart'; 9 | 10 | class ItemBottomSheet extends StatelessWidget { 11 | const ItemBottomSheet( 12 | this._itemCubit, 13 | this._authCubit, 14 | this._settingsCubit, { 15 | super.key, 16 | }); 17 | 18 | final ItemCubit _itemCubit; 19 | final AuthCubit _authCubit; 20 | final SettingsCubit _settingsCubit; 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return BlocBuilder( 25 | bloc: _itemCubit, 26 | builder: (context, state) => BlocBuilder( 27 | bloc: _authCubit, 28 | builder: (context, authState) => 29 | BlocBuilder( 30 | bloc: _settingsCubit, 31 | builder: (context, settingsState) => 32 | NotificationCanceler( 33 | child: ListView( 34 | primary: false, 35 | shrinkWrap: true, 36 | children: [ 37 | for (final action in ItemAction.values) 38 | if (action.isVisible(state, authState, settingsState)) 39 | ListTile( 40 | leading: Icon(action.icon(state)), 41 | title: Text(action.label(context, state)), 42 | trailing: action.options != null 43 | ? const Icon(Icons.chevron_right) 44 | : null, 45 | onTap: () async { 46 | context.pop(); 47 | await action.execute( 48 | context, 49 | _itemCubit, 50 | _authCubit, 51 | ); 52 | }, 53 | ), 54 | ], 55 | ), 56 | ), 57 | ), 58 | ), 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | id "dev.flutter.flutter-gradle-plugin" 5 | } 6 | 7 | def localProperties = new Properties() 8 | def localPropertiesFile = rootProject.file('local.properties') 9 | if (localPropertiesFile.exists()) { 10 | localPropertiesFile.withReader('UTF-8') { reader -> 11 | localProperties.load(reader) 12 | } 13 | } 14 | 15 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 16 | if (flutterVersionCode == null) { 17 | flutterVersionCode = '1' 18 | } 19 | 20 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 21 | if (flutterVersionName == null) { 22 | flutterVersionName = '1.0' 23 | } 24 | 25 | def keystoreProperties = new Properties() 26 | def keystorePropertiesFile = rootProject.file('key.properties') 27 | if (keystorePropertiesFile.exists()) { 28 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 29 | } 30 | 31 | android { 32 | namespace 'nl.viter.glider' 33 | compileSdk flutter.compileSdkVersion 34 | ndkVersion '26.2.11394342' 35 | 36 | compileOptions { 37 | sourceCompatibility JavaVersion.VERSION_1_8 38 | targetCompatibility JavaVersion.VERSION_1_8 39 | } 40 | 41 | kotlinOptions { 42 | jvmTarget = '1.8' 43 | } 44 | 45 | sourceSets { 46 | main.java.srcDirs += 'src/main/kotlin' 47 | } 48 | 49 | defaultConfig { 50 | applicationId 'nl.viter.glider' 51 | minSdk 21 52 | targetSdk flutter.targetSdkVersion 53 | versionCode flutterVersionCode.toInteger() 54 | versionName flutterVersionName 55 | } 56 | 57 | signingConfigs { 58 | release { 59 | keyAlias keystoreProperties['keyAlias'] 60 | keyPassword keystoreProperties['keyPassword'] 61 | storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 62 | storePassword keystoreProperties['storePassword'] 63 | } 64 | } 65 | 66 | buildTypes { 67 | release { 68 | signingConfig signingConfigs.release 69 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 70 | } 71 | } 72 | } 73 | 74 | flutter { 75 | source '../..' 76 | } 77 | 78 | dependencies { 79 | implementation 'com.google.android.material:material:1.11.0' 80 | } 81 | -------------------------------------------------------------------------------- /lib/common/widgets/preview_bottom_panel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:glider/common/constants/app_spacing.dart'; 3 | import 'package:glider/common/widgets/animated_visibility.dart'; 4 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 5 | 6 | class PreviewBottomPanel extends StatelessWidget { 7 | const PreviewBottomPanel({ 8 | super.key, 9 | required this.visible, 10 | this.onChanged, 11 | required this.child, 12 | }); 13 | 14 | final bool visible; 15 | final void Function(bool)? onChanged; 16 | final Widget child; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return SafeArea( 21 | child: Column( 22 | mainAxisSize: MainAxisSize.min, 23 | children: [ 24 | Flexible( 25 | // Use a third of the available height for the preview (if visible). 26 | // The layout builder helps exclude any keyboard's view insets. 27 | child: LayoutBuilder( 28 | builder: (context, constraints) => ConstrainedBox( 29 | constraints: BoxConstraints( 30 | maxHeight: constraints.maxHeight / 3, 31 | ), 32 | child: AnimatedVisibility.vertical( 33 | visible: visible, 34 | child: Column( 35 | mainAxisSize: MainAxisSize.min, 36 | children: [ 37 | const Divider(height: 1), 38 | Flexible(child: child), 39 | ], 40 | ), 41 | ), 42 | ), 43 | ), 44 | ), 45 | ConstrainedBox( 46 | constraints: BoxConstraints( 47 | minHeight: MediaQuery.viewInsetsOf(context).bottom, 48 | ), 49 | // Alignment may seem unnecesary because it is hidden behind a 50 | // keyboard when relevant, but keyboards may be translucent. 51 | child: Align( 52 | alignment: Alignment.topCenter, 53 | child: SwitchListTile.adaptive( 54 | value: visible, 55 | onChanged: onChanged, 56 | title: Text(context.l10n.preview), 57 | contentPadding: 58 | const EdgeInsets.symmetric(horizontal: AppSpacing.xl), 59 | ), 60 | ), 61 | ), 62 | ], 63 | ), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/settings/view/theme_color_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:glider/common/constants/app_spacing.dart'; 4 | import 'package:glider/l10n/extensions/app_localizations_extension.dart'; 5 | import 'package:glider/settings/cubit/settings_cubit.dart'; 6 | import 'package:go_router/go_router.dart'; 7 | 8 | const _totalColors = 20; 9 | 10 | final _colors = [ 11 | for (var i = 0; i < _totalColors; i++) 12 | HSVColor.fromAHSV(1, 360 / _totalColors * i, 0.5, 1).toColor(), 13 | ]; 14 | 15 | const _iconSize = 40.0; 16 | 17 | class ThemeColorDialog extends StatelessWidget { 18 | const ThemeColorDialog(this._settingsCubit, {super.key}); 19 | 20 | final SettingsCubit _settingsCubit; 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return AlertDialog( 25 | title: Text(context.l10n.themeColor), 26 | contentPadding: AppSpacing.defaultTilePadding, 27 | content: SizedBox( 28 | width: 0, 29 | child: _ThemeColorBody(_settingsCubit), 30 | ), 31 | actions: [ 32 | TextButton( 33 | onPressed: () => context.pop(), 34 | child: Text(MaterialLocalizations.of(context).okButtonLabel), 35 | ), 36 | ], 37 | ); 38 | } 39 | } 40 | 41 | class _ThemeColorBody extends StatelessWidget { 42 | const _ThemeColorBody(this._settingsCubit); 43 | 44 | final SettingsCubit _settingsCubit; 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return BlocBuilder( 49 | bloc: _settingsCubit, 50 | buildWhen: (previous, current) => 51 | previous.themeColor != current.themeColor, 52 | builder: (context, state) => GridView.extent( 53 | maxCrossAxisExtent: 64, 54 | shrinkWrap: true, 55 | children: [ 56 | for (final color in _colors) 57 | IconButton( 58 | icon: Icon( 59 | Icons.circle_outlined, 60 | color: color, 61 | size: _iconSize, 62 | ), 63 | selectedIcon: Icon( 64 | Icons.circle, 65 | color: color, 66 | size: _iconSize, 67 | ), 68 | isSelected: color == state.themeColor, 69 | padding: const EdgeInsets.all(AppSpacing.m), 70 | onPressed: () => _settingsCubit.setThemeColor(color), 71 | ), 72 | ], 73 | ), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/reply/cubit/reply_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:formz/formz.dart'; 5 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 6 | import 'package:glider/reply/models/text_input.dart'; 7 | import 'package:glider_domain/glider_domain.dart'; 8 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 9 | 10 | part 'reply_state.dart'; 11 | 12 | class ReplyCubit extends HydratedCubit { 13 | ReplyCubit( 14 | this._itemRepository, 15 | this._itemInteractionRepository, { 16 | required int id, 17 | }) : itemId = id, 18 | super(const ReplyState()) { 19 | _itemSubscription = _itemRepository.getItemStream(itemId).listen( 20 | (item) => safeEmit( 21 | state.copyWith(parentItem: () => item), 22 | ), 23 | ); 24 | } 25 | 26 | final ItemRepository _itemRepository; 27 | final ItemInteractionRepository _itemInteractionRepository; 28 | final int itemId; 29 | 30 | late final StreamSubscription _itemSubscription; 31 | 32 | @override 33 | String get id => itemId.toString(); 34 | 35 | void setText(String text) { 36 | final textInput = TextInput.dirty(text); 37 | safeEmit( 38 | state.copyWith( 39 | text: () => textInput, 40 | isValid: () => Formz.validate([textInput]), 41 | ), 42 | ); 43 | } 44 | 45 | void setPreview(bool preview) { 46 | safeEmit( 47 | state.copyWith(preview: () => preview), 48 | ); 49 | } 50 | 51 | void quoteParent() { 52 | final quotedParent = state.parentItem!.text! 53 | .splitMapJoin('\n', onNonMatch: (m) => '> $m'.trimRight()); 54 | final textInput = TextInput.dirty('$quotedParent\n\n${state.text.value}'); 55 | safeEmit( 56 | state.copyWith( 57 | text: () => textInput, 58 | isValid: () => Formz.validate([textInput]), 59 | ), 60 | ); 61 | } 62 | 63 | Future reply() async { 64 | final success = 65 | await _itemInteractionRepository.reply(itemId, text: state.text.value); 66 | safeEmit( 67 | success 68 | ? const ReplyState(success: true) 69 | : state.copyWith(success: () => false), 70 | ); 71 | } 72 | 73 | @override 74 | ReplyState? fromJson(Map json) => ReplyState.fromMap(json); 75 | 76 | @override 77 | Map? toJson(ReplyState state) => state.toMap(); 78 | 79 | @override 80 | Future close() async { 81 | await _itemSubscription.cancel(); 82 | return super.close(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/item/widgets/avatar_widget.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:flutter/widgets.dart'; 5 | 6 | class AvatarWidget extends StatelessWidget { 7 | AvatarWidget({required this.username}) : super(key: ValueKey(username)); 8 | 9 | final String username; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final pixelSize = MediaQuery.textScalerOf(context).scale(2); 14 | final avatarSize = pixelSize * 7; 15 | 16 | return CustomPaint( 17 | painter: _AvatarPainter( 18 | username: username, 19 | pixelSize: pixelSize, 20 | offset: Offset(pixelSize / 2, pixelSize / 2), 21 | ), 22 | size: Size.square(avatarSize), 23 | ); 24 | } 25 | } 26 | 27 | // Algorithm based on https://news.ycombinator.com/item?id=30668207 by tomxor. 28 | class _AvatarPainter extends CustomPainter with EquatableMixin { 29 | const _AvatarPainter({ 30 | required this.username, 31 | required this.pixelSize, 32 | required this.offset, 33 | }); 34 | 35 | final String username; 36 | final double pixelSize; 37 | final Offset offset; 38 | 39 | @override 40 | void paint(Canvas canvas, Size size) { 41 | const seedSteps = 28; 42 | final points = []; 43 | final paint = Paint()..strokeWidth = pixelSize; 44 | var seed = 1; 45 | 46 | for (var i = seedSteps + username.length - 1; i >= seedSteps; i--) { 47 | seed = _xorShift32(seed); 48 | seed += username.codeUnitAt(i - seedSteps); 49 | } 50 | 51 | paint.color = Color(seed >> 8 | 0xff000000); 52 | 53 | for (var i = seedSteps - 1; i >= 0; i--) { 54 | seed = _xorShift32(seed); 55 | 56 | final x = i & 3; 57 | final y = i >> 2; 58 | 59 | if (seed.toUnsigned(32) >> seedSteps + 1 > x * x / 3 + y / 2) { 60 | points 61 | ..add(Offset(pixelSize * 3 + pixelSize * x, pixelSize * y) + offset) 62 | ..add(Offset(pixelSize * 3 - pixelSize * x, pixelSize * y) + offset); 63 | } 64 | } 65 | 66 | canvas.drawPoints(PointMode.points, points, paint); 67 | } 68 | 69 | @override 70 | bool shouldRepaint(covariant CustomPainter oldDelegate) => 71 | oldDelegate != this; 72 | 73 | @override 74 | List get props => [ 75 | username, 76 | pixelSize, 77 | offset, 78 | ]; 79 | 80 | static int _xorShift32(int number) { 81 | var result = number; 82 | result ^= result << 13; 83 | result ^= result.toUnsigned(32) >> 17; 84 | result ^= result << 5; 85 | return result; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.lock 4 | *.log 5 | *.pyc 6 | *.swp 7 | .DS_Store 8 | .atom/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/* 18 | 19 | # Visual Studio Code related 20 | .classpath 21 | .project 22 | .settings/ 23 | .vscode/* 24 | 25 | # packages file containing multi-root paths 26 | .packages.generated 27 | 28 | # Flutter/Dart/Pub related 29 | **/doc/api/ 30 | **/ios/Flutter/.last_build_id 31 | .dart_tool/ 32 | .flutter-plugins 33 | .flutter-plugins-dependencies 34 | .fvm/ 35 | .packages 36 | .pub-cache/ 37 | .pub/ 38 | build/ 39 | flutter_*.png 40 | linked_*.ds 41 | pubspec_overrides.yaml 42 | unlinked.ds 43 | unlinked_spec.ds 44 | 45 | # Android related 46 | **/android/**/gradle-wrapper.jar 47 | **/android/.gradle 48 | **/android/captures/ 49 | **/android/local.properties 50 | **/android/**/GeneratedPluginRegistrant.java 51 | **/android/key.properties 52 | **/android/.idea/ 53 | **/android/app/debug 54 | **/android/app/profile 55 | **/android/app/release 56 | *.jks 57 | 58 | # iOS/XCode related 59 | **/ios/**/*.mode1v3 60 | **/ios/**/*.mode2v3 61 | **/ios/**/*.moved-aside 62 | **/ios/**/*.pbxuser 63 | **/ios/**/*.perspectivev3 64 | **/ios/**/*sync/ 65 | **/ios/**/.sconsign.dblite 66 | **/ios/**/.tags* 67 | **/ios/**/.vagrant/ 68 | **/ios/**/DerivedData/ 69 | **/ios/**/Icon? 70 | **/ios/**/Pods/ 71 | **/ios/**/.symlinks/ 72 | **/ios/**/profile 73 | **/ios/**/xcuserdata 74 | **/ios/.generated/ 75 | **/ios/Flutter/App.framework 76 | **/ios/Flutter/Flutter.framework 77 | **/ios/Flutter/Flutter.podspec 78 | **/ios/Flutter/Generated.xcconfig 79 | **/ios/Flutter/app.flx 80 | **/ios/Flutter/app.zip 81 | **/ios/Flutter/.last_build_id 82 | **/ios/Flutter/flutter_assets/ 83 | **/ios/Flutter/flutter_export_environment.sh 84 | **/ios/ServiceDefinitions.json 85 | **/ios/Runner/GeneratedPluginRegistrant.* 86 | 87 | # Coverage 88 | coverage/ 89 | 90 | # Submodules 91 | !pubspec.lock 92 | packages/**/pubspec.lock 93 | 94 | # Web related 95 | lib/generated_plugin_registrant.dart 96 | 97 | # Symbolication related 98 | app.*.symbols 99 | 100 | # Obfuscation related 101 | app.*.map.json 102 | 103 | # Exceptions to the above rules. 104 | !**/ios/**/default.mode1v3 105 | !**/ios/**/default.mode2v3 106 | !**/ios/**/default.pbxuser 107 | !**/ios/**/default.perspectivev3 108 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 109 | !/dev/ci/**/Gemfile.lock 110 | !.vscode/extensions.json 111 | !.vscode/launch.json 112 | -------------------------------------------------------------------------------- /lib/story_similar/cubit/story_similar_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:glider/common/extensions/bloc_base_extension.dart'; 5 | import 'package:glider/common/mixins/data_mixin.dart'; 6 | import 'package:glider/common/models/status.dart'; 7 | import 'package:glider_domain/glider_domain.dart'; 8 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 9 | 10 | part 'story_similar_state.dart'; 11 | 12 | class StorySimilarCubit extends HydratedCubit { 13 | StorySimilarCubit(this._itemRepository, {required int id}) 14 | : itemId = id, 15 | super(const StorySimilarState()) { 16 | safeEmit( 17 | state.copyWith(status: () => Status.loading), 18 | ); 19 | _itemSubscription = _itemRepository.getItemStream(itemId).listen( 20 | (item) async { 21 | if (item.url != state.item?.url) { 22 | safeEmit( 23 | state.copyWith(item: () => item), 24 | ); 25 | 26 | await _load(); 27 | } 28 | }, 29 | // ignore: avoid_types_on_closure_parameters 30 | onError: (Object exception) => safeEmit( 31 | state.copyWith( 32 | status: () => Status.failure, 33 | exception: () => exception, 34 | ), 35 | ), 36 | ); 37 | } 38 | 39 | final ItemRepository _itemRepository; 40 | final int itemId; 41 | 42 | late final StreamSubscription _itemSubscription; 43 | 44 | @override 45 | String get id => itemId.toString(); 46 | 47 | Future _load() async { 48 | if (state.item?.url case final url?) { 49 | try { 50 | final similarStories = await _itemRepository.getSimilarStories( 51 | itemId, 52 | url: url.toString(), 53 | ); 54 | safeEmit( 55 | state.copyWith( 56 | status: () => Status.success, 57 | data: () => 58 | similarStories.map((item) => item.id).toList(growable: false), 59 | exception: () => null, 60 | ), 61 | ); 62 | } on Object catch (exception) { 63 | safeEmit( 64 | state.copyWith( 65 | status: () => Status.failure, 66 | exception: () => exception, 67 | ), 68 | ); 69 | } 70 | } 71 | } 72 | 73 | @override 74 | StorySimilarState? fromJson(Map json) => 75 | StorySimilarState.fromMap(json); 76 | 77 | @override 78 | Map? toJson(StorySimilarState state) => 79 | state.status == Status.success ? state.toMap() : null; 80 | 81 | @override 82 | Future close() { 83 | _itemSubscription.cancel(); 84 | return super.close(); 85 | } 86 | } 87 | --------------------------------------------------------------------------------