diff --git a/.github/workflows/instances.yml b/.github/workflows/instances.yml index 424eb4cbc..601de2ab9 100644 --- a/.github/workflows/instances.yml +++ b/.github/workflows/instances.yml @@ -1,5 +1,5 @@ -name: instances - +name: instances + on: push: branches: @@ -12,28 +12,28 @@ permissions: jobs: instances: - name: Instances - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Query Instances - run: | - # Query the list of Lemmy instances - curl -H 'Content-Type: application/json' -X POST \ - -d '{"query": "query {nodes(softwarename: \"lemmy\") {domain active_users_monthly}}"}' https://api.fediverse.observer 2> /dev/null | \ - jq -r '.data.nodes | .[] | select(.active_users_monthly > 50) | .domain' | sort | uniq -i > lemmy-instances.txt - - # Query the list of PieFed instances - curl -H 'Content-Type: application/json' -X POST \ - -d '{"query": "query {nodes(softwarename: \"piefed\") {domain active_users_monthly}}"}' https://api.fediverse.observer 2> /dev/null | \ - jq -r '.data.nodes | .[] | select(.active_users_monthly > 50) | .domain' | sort | uniq -i > piefed-instances.txt - - # Combine both lemmy and piefed instances into a single sorted list for Android/Safari - cat lemmy-instances.txt piefed-instances.txt | sort | uniq -i > instances.txt - - # Convert to a dart file with a map of domain -> platform + name: Instances + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Query Instances + run: | + # Query the list of Lemmy instances + curl -H 'Content-Type: application/json' -X POST \ + -d '{"query": "query {nodes(softwarename: \"lemmy\") {domain active_users_monthly}}"}' https://api.fediverse.observer 2> /dev/null | \ + jq -r '.data.nodes | .[] | select(.active_users_monthly > 50) | .domain' | sort | uniq -i > lemmy-instances.txt + + # Query the list of PieFed instances + curl -H 'Content-Type: application/json' -X POST \ + -d '{"query": "query {nodes(softwarename: \"piefed\") {domain active_users_monthly}}"}' https://api.fediverse.observer 2> /dev/null | \ + jq -r '.data.nodes | .[] | select(.active_users_monthly > 50) | .domain' | sort | uniq -i > piefed-instances.txt + + # Combine both lemmy and piefed instances into a single sorted list for Android/Safari + cat lemmy-instances.txt piefed-instances.txt | sort | uniq -i > instances.txt + + # Convert to a dart file with a map of domain -> platform cat << EOF > lib/src/features/instance/data/constants/known_instances.dart import 'package:thunder/src/foundation/primitives/primitives.dart'; @@ -42,109 +42,109 @@ jobs: $(awk '{ print " \047"$0"\047: ThreadiversePlatform.piefed," }' piefed-instances.txt) }; EOF - - # Put the instances in the Android manifest file - manifestInstances="$(awk '{ print " " }' instances.txt)" - inSection=false - while IFS= read -r line; do - if [[ $line == *"#AUTO_GEN_INSTANCE_LIST_DO_NOT_TOUCH#"* ]]; then - inSection=true - fi - - if [[ $line == *"#INSTANCE_LIST_END#"* ]]; then - echo "$manifestInstances" >> android/app/src/main/AndroidManifest-new.xml - inSection=false - fi - - if [[ $line == *"android:host"* ]]; then - if [ "$inSection" = true ]; then - continue - fi - fi - - echo "$line" >> android/app/src/main/AndroidManifest-new.xml - done < android/app/src/main/AndroidManifest.xml - mv android/app/src/main/AndroidManifest-new.xml android/app/src/main/AndroidManifest.xml - - # ---------- Safari Extension ---------- - totalLines=$(wc -l < instances.txt) - currentLine=0 - - safariManifestInstances="" - safariContentInstances="" - - # Generate the Safari extension domains used in manifest.json and content.js - # It ignores the last comma in the list to generate proper json - while IFS= read -r instance; do - currentLine=$((currentLine + 1)) - - if [ "$currentLine" -eq 1 ]; then - # First line - safariManifestInstances=" \"*://$instance/*\"" - safariContentInstances=" \"$instance\"" - elif [ "$currentLine" -eq "$totalLines" ]; then - # Last line - safariManifestInstances="$safariManifestInstances,\n \"*://$instance/*\"\n" - safariContentInstances="$safariContentInstances,\n \"$instance\"\n" - else - safariManifestInstances="$safariManifestInstances,\n \"*://$instance/*\"" - safariContentInstances="$safariContentInstances,\n \"$instance\"" - fi - done < instances.txt - - # Generates the new manifest.json with the updated instances - inSection=false - while IFS= read -r line; do - if [[ $line == *"matches\": ["* ]]; then - inSection=true - fi - - if [[ $line == " ]" ]]; then - printf "$safariManifestInstances" >> "ios/Open In Thunder/Resources/manifest-new.json" - inSection=false - fi - - if [[ $line == *"*://"* ]]; then - if [ "$inSection" = true ]; then - continue - fi - fi - - echo "$line" >> "ios/Open In Thunder/Resources/manifest-new.json" - done < "ios/Open In Thunder/Resources/manifest.json" - mv "ios/Open In Thunder/Resources/manifest-new.json" "ios/Open In Thunder/Resources/manifest.json" - - # Generates the new content.js with the updated instances - inSection=false - while IFS= read -r line; do - if [[ $line == *"let instances = ["* ]]; then - inSection=true - fi - - if [[ $line == "];" ]]; then - printf "$safariContentInstances" >> "ios/Open In Thunder/Resources/content-new.js" - inSection=false - fi - - if [[ $line == *" \""* ]]; then - if [ "$inSection" = true ]; then - continue - fi - fi - - echo "$line" >> "ios/Open In Thunder/Resources/content-new.js" - done < "ios/Open In Thunder/Resources/content.js" - mv "ios/Open In Thunder/Resources/content-new.js" "ios/Open In Thunder/Resources/content.js" - - # Clean up temporary txt files - rm lemmy-instances.txt piefed-instances.txt instances.txt - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v6.0.1 - with: - commit-message: Update instances - title: Update instances + + # Put the instances in the Android manifest file + manifestInstances="$(awk '{ print " " }' instances.txt)" + inSection=false + while IFS= read -r line; do + if [[ $line == *"#AUTO_GEN_INSTANCE_LIST_DO_NOT_TOUCH#"* ]]; then + inSection=true + fi + + if [[ $line == *"#INSTANCE_LIST_END#"* ]]; then + echo "$manifestInstances" >> android/app/src/main/AndroidManifest-new.xml + inSection=false + fi + + if [[ $line == *"android:host"* ]]; then + if [ "$inSection" = true ]; then + continue + fi + fi + + echo "$line" >> android/app/src/main/AndroidManifest-new.xml + done < android/app/src/main/AndroidManifest.xml + mv android/app/src/main/AndroidManifest-new.xml android/app/src/main/AndroidManifest.xml + + # ---------- Safari Extension ---------- + totalLines=$(wc -l < instances.txt) + currentLine=0 + + safariManifestInstances="" + safariContentInstances="" + + # Generate the Safari extension domains used in manifest.json and content.js + # It ignores the last comma in the list to generate proper json + while IFS= read -r instance; do + currentLine=$((currentLine + 1)) + + if [ "$currentLine" -eq 1 ]; then + # First line + safariManifestInstances=" \"*://$instance/*\"" + safariContentInstances=" \"$instance\"" + elif [ "$currentLine" -eq "$totalLines" ]; then + # Last line + safariManifestInstances="$safariManifestInstances,\n \"*://$instance/*\"\n" + safariContentInstances="$safariContentInstances,\n \"$instance\"\n" + else + safariManifestInstances="$safariManifestInstances,\n \"*://$instance/*\"" + safariContentInstances="$safariContentInstances,\n \"$instance\"" + fi + done < instances.txt + + # Generates the new manifest.json with the updated instances + inSection=false + while IFS= read -r line; do + if [[ $line == *"matches\": ["* ]]; then + inSection=true + fi + + if [[ $line == " ]" ]]; then + printf "$safariManifestInstances" >> "ios/Open In Thunder/Resources/manifest-new.json" + inSection=false + fi + + if [[ $line == *"*://"* ]]; then + if [ "$inSection" = true ]; then + continue + fi + fi + + echo "$line" >> "ios/Open In Thunder/Resources/manifest-new.json" + done < "ios/Open In Thunder/Resources/manifest.json" + mv "ios/Open In Thunder/Resources/manifest-new.json" "ios/Open In Thunder/Resources/manifest.json" + + # Generates the new content.js with the updated instances + inSection=false + while IFS= read -r line; do + if [[ $line == *"let instances = ["* ]]; then + inSection=true + fi + + if [[ $line == "];" ]]; then + printf "$safariContentInstances" >> "ios/Open In Thunder/Resources/content-new.js" + inSection=false + fi + + if [[ $line == *" \""* ]]; then + if [ "$inSection" = true ]; then + continue + fi + fi + + echo "$line" >> "ios/Open In Thunder/Resources/content-new.js" + done < "ios/Open In Thunder/Resources/content.js" + mv "ios/Open In Thunder/Resources/content-new.js" "ios/Open In Thunder/Resources/content.js" + + # Clean up temporary txt files + rm lemmy-instances.txt piefed-instances.txt instances.txt + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6.0.1 + with: + commit-message: Update instances + title: Update instances body: This PR is updating `known_instances.dart`, `AndroidManifest.xml`, `manifest.json` and `content.js` with the latest list of Lemmy and PieFed instances retrieved from fediverse.observer. - branch: update-instances - delete-branch: true - author: GitHub + branch: update-instances + delete-branch: true + author: GitHub diff --git a/android/app/src/development/AndroidManifest.xml b/android/app/src/development/AndroidManifest.xml new file mode 100644 index 000000000..16c8b173b --- /dev/null +++ b/android/app/src/development/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/development/res/xml/network_security_config.xml b/android/app/src/development/res/xml/network_security_config.xml new file mode 100644 index 000000000..aca78d2e5 --- /dev/null +++ b/android/app/src/development/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fonts/README.md b/fonts/README.md index 9add1c69a..3346e6919 100644 --- a/fonts/README.md +++ b/fonts/README.md @@ -1,8 +1,8 @@ -## How to Add Icons to Thunder Font - -1. Go to https://www.fluttericon.com/, press the settings wrench, and import `config.json` from this directory. -2. Select any icons from that website or upload external ones. -3. Press Download in the top-right. -4. Extract the resulting zip file and... - * Update `Thunder.ttf` and `config.json` in this directory. - * Place `thunder_icons.dart` in `lib/thunder/`. +## How to Add Icons to Thunder Font + +1. Go to https://www.fluttericon.com/, press the settings wrench, and import `config.json` from this directory. +2. Select any icons from that website or upload external ones. +3. Press Download in the top-right. +4. Extract the resulting zip file and... + * Update `Thunder.ttf` and `config.json` in this directory. + * Place `thunder_icon.dart` in `lib/packages/ui/src/icons/`. diff --git a/lib/packages/ui/src/icons/thunder_icon.dart b/lib/packages/ui/src/icons/thunder_icon.dart new file mode 100644 index 000000000..709d99086 --- /dev/null +++ b/lib/packages/ui/src/icons/thunder_icon.dart @@ -0,0 +1,16 @@ +// ignore_for_file: constant_identifier_names + +import 'package:flutter/widgets.dart'; + +/// Custom icon font constants for Thunder. +class ThunderIcon { + ThunderIcon._(); + + static const _kFontFam = 'Thunder'; + static const String? _kFontPkg = null; + + static const IconData microphone_variant = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData shield = IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData shield_crown = IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData robot = IconData(0xf544, fontFamily: _kFontFam, fontPackage: _kFontPkg); +} diff --git a/lib/packages/ui/src/icons/thunder_icons.dart b/lib/packages/ui/src/icons/thunder_icons.dart deleted file mode 100644 index 911f9854c..000000000 --- a/lib/packages/ui/src/icons/thunder_icons.dart +++ /dev/null @@ -1,34 +0,0 @@ -// ignore_for_file: constant_identifier_names, dangling_library_doc_comments - -/// Flutter icons Thunder -/// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com -/// This font was generated by FlutterIcon.com, which is derived from Fontello. -/// -/// To use this font, place it in your fonts/ directory and include the -/// following in your pubspec.yaml -/// -/// flutter: -/// fonts: -/// - family: Thunder -/// fonts: -/// - asset: fonts/Thunder.ttf -/// -/// -/// * Font Awesome 5, Copyright (C) 2016 by Dave Gandy -/// Author: Dave Gandy -/// License: SIL (https://github.com/FortAwesome/Font-Awesome/blob/master/LICENSE.txt) -/// Homepage: http://fortawesome.github.com/Font-Awesome/ -/// -import 'package:flutter/widgets.dart'; - -class Thunder { - Thunder._(); - - static const _kFontFam = 'Thunder'; - static const String? _kFontPkg = null; - - static const IconData microphone_variant = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); - static const IconData shield = IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg); - static const IconData shield_crown = IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg); - static const IconData robot = IconData(0xf544, fontFamily: _kFontFam, fontPackage: _kFontPkg); -} diff --git a/lib/packages/ui/src/theme/thunder_theme.dart b/lib/packages/ui/src/theme/thunder_theme.dart new file mode 100644 index 000000000..0b02416d3 --- /dev/null +++ b/lib/packages/ui/src/theme/thunder_theme.dart @@ -0,0 +1,144 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// Thunder-specific design tokens for shared UI package widgets. +@immutable +class ThunderTheme extends ThemeExtension { + /// The rounded shape used by pill-like tiles, pickers, and action surfaces. + final BorderRadius tileBorderRadius; + + /// The icon size used by spacious state views. + final double stateIconSizeLarge; + + /// The icon size used by compact state views. + final double stateIconSizeCompact; + + /// The opacity applied to secondary or muted foreground text. + final double mutedTextAlpha; + + /// The horizontal inset used by sidebar section dividers. + final double sidebarDividerIndent; + + /// The background color behind avatar images. + final Color avatarImageBackgroundColor; + + /// The opacity applied to settings tile subtitles. + final double settingsTileSubtitleAlpha; + + /// The opacity applied to disabled settings tile content. + final double settingsTileDisabledAlpha; + + /// The horizontal gap between a settings tile leading widget and title. + final double settingsTileLeadingGap; + + /// The width of the trailing control column on settings tiles. + final double settingsTileTrailingSlotWidth; + + /// The height of the trailing control column on settings tiles. + final double settingsTileTrailingSlotHeight; + + /// The opacity applied to selected selectable-tile backgrounds. + final double selectableTileSelectedAlpha; + + /// The opacity applied to selected picker item backgrounds. + final double pickerSelectedAlpha; + + /// The opacity applied to section description text. + final double sectionDescriptionAlpha; + + /// The background color used by the full-screen image viewer. + final Color viewerBackgroundColor; + + /// The color used by the image viewer's default error icon. + final Color viewerErrorIconColor; + + const ThunderTheme({ + this.tileBorderRadius = const BorderRadius.all(Radius.circular(50.0)), + this.stateIconSizeLarge = 100.0, + this.stateIconSizeCompact = 40.0, + this.mutedTextAlpha = 0.55, + this.sidebarDividerIndent = 15.0, + this.avatarImageBackgroundColor = Colors.transparent, + this.settingsTileSubtitleAlpha = 0.8, + this.settingsTileDisabledAlpha = 0.5, + this.settingsTileLeadingGap = 8.0, + this.settingsTileTrailingSlotWidth = 60.0, + this.settingsTileTrailingSlotHeight = 42.0, + this.selectableTileSelectedAlpha = 0.35, + this.pickerSelectedAlpha = 0.25, + this.sectionDescriptionAlpha = 0.75, + this.viewerBackgroundColor = Colors.black, + this.viewerErrorIconColor = Colors.white70, + }); + + /// Returns the nearest [ThunderTheme] from [context], or defaults. + static ThunderTheme of(BuildContext context) { + return Theme.of(context).extension() ?? const ThunderTheme(); + } + + /// Creates a copy of this extension with the provided token overrides. + @override + ThunderTheme copyWith({ + BorderRadius? tileBorderRadius, + double? stateIconSizeLarge, + double? stateIconSizeCompact, + double? mutedTextAlpha, + double? sidebarDividerIndent, + Color? avatarImageBackgroundColor, + double? settingsTileSubtitleAlpha, + double? settingsTileDisabledAlpha, + double? settingsTileLeadingGap, + double? settingsTileTrailingSlotWidth, + double? settingsTileTrailingSlotHeight, + double? selectableTileSelectedAlpha, + double? pickerSelectedAlpha, + double? sectionDescriptionAlpha, + Color? viewerBackgroundColor, + Color? viewerErrorIconColor, + }) { + return ThunderTheme( + tileBorderRadius: tileBorderRadius ?? this.tileBorderRadius, + stateIconSizeLarge: stateIconSizeLarge ?? this.stateIconSizeLarge, + stateIconSizeCompact: stateIconSizeCompact ?? this.stateIconSizeCompact, + mutedTextAlpha: mutedTextAlpha ?? this.mutedTextAlpha, + sidebarDividerIndent: sidebarDividerIndent ?? this.sidebarDividerIndent, + avatarImageBackgroundColor: avatarImageBackgroundColor ?? this.avatarImageBackgroundColor, + settingsTileSubtitleAlpha: settingsTileSubtitleAlpha ?? this.settingsTileSubtitleAlpha, + settingsTileDisabledAlpha: settingsTileDisabledAlpha ?? this.settingsTileDisabledAlpha, + settingsTileLeadingGap: settingsTileLeadingGap ?? this.settingsTileLeadingGap, + settingsTileTrailingSlotWidth: settingsTileTrailingSlotWidth ?? this.settingsTileTrailingSlotWidth, + settingsTileTrailingSlotHeight: settingsTileTrailingSlotHeight ?? this.settingsTileTrailingSlotHeight, + selectableTileSelectedAlpha: selectableTileSelectedAlpha ?? this.selectableTileSelectedAlpha, + pickerSelectedAlpha: pickerSelectedAlpha ?? this.pickerSelectedAlpha, + sectionDescriptionAlpha: sectionDescriptionAlpha ?? this.sectionDescriptionAlpha, + viewerBackgroundColor: viewerBackgroundColor ?? this.viewerBackgroundColor, + viewerErrorIconColor: viewerErrorIconColor ?? this.viewerErrorIconColor, + ); + } + + /// Linearly interpolates between this extension and [other]. + @override + ThunderTheme lerp(ThemeExtension? other, double t) { + if (other is! ThunderTheme) return this; + + return ThunderTheme( + tileBorderRadius: BorderRadius.lerp(tileBorderRadius, other.tileBorderRadius, t) ?? tileBorderRadius, + stateIconSizeLarge: lerpDouble(stateIconSizeLarge, other.stateIconSizeLarge, t) ?? stateIconSizeLarge, + stateIconSizeCompact: lerpDouble(stateIconSizeCompact, other.stateIconSizeCompact, t) ?? stateIconSizeCompact, + mutedTextAlpha: lerpDouble(mutedTextAlpha, other.mutedTextAlpha, t) ?? mutedTextAlpha, + sidebarDividerIndent: lerpDouble(sidebarDividerIndent, other.sidebarDividerIndent, t) ?? sidebarDividerIndent, + avatarImageBackgroundColor: Color.lerp(avatarImageBackgroundColor, other.avatarImageBackgroundColor, t) ?? avatarImageBackgroundColor, + settingsTileSubtitleAlpha: lerpDouble(settingsTileSubtitleAlpha, other.settingsTileSubtitleAlpha, t) ?? settingsTileSubtitleAlpha, + settingsTileDisabledAlpha: lerpDouble(settingsTileDisabledAlpha, other.settingsTileDisabledAlpha, t) ?? settingsTileDisabledAlpha, + settingsTileLeadingGap: lerpDouble(settingsTileLeadingGap, other.settingsTileLeadingGap, t) ?? settingsTileLeadingGap, + settingsTileTrailingSlotWidth: lerpDouble(settingsTileTrailingSlotWidth, other.settingsTileTrailingSlotWidth, t) ?? settingsTileTrailingSlotWidth, + settingsTileTrailingSlotHeight: lerpDouble(settingsTileTrailingSlotHeight, other.settingsTileTrailingSlotHeight, t) ?? settingsTileTrailingSlotHeight, + selectableTileSelectedAlpha: lerpDouble(selectableTileSelectedAlpha, other.selectableTileSelectedAlpha, t) ?? selectableTileSelectedAlpha, + pickerSelectedAlpha: lerpDouble(pickerSelectedAlpha, other.pickerSelectedAlpha, t) ?? pickerSelectedAlpha, + sectionDescriptionAlpha: lerpDouble(sectionDescriptionAlpha, other.sectionDescriptionAlpha, t) ?? sectionDescriptionAlpha, + viewerBackgroundColor: Color.lerp(viewerBackgroundColor, other.viewerBackgroundColor, t) ?? viewerBackgroundColor, + viewerErrorIconColor: Color.lerp(viewerErrorIconColor, other.viewerErrorIconColor, t) ?? viewerErrorIconColor, + ); + } +} diff --git a/lib/packages/ui/src/widgets/actions/thunder_action_chip.dart b/lib/packages/ui/src/widgets/actions/thunder_action_chip.dart index 50680866d..723296e97 100644 --- a/lib/packages/ui/src/widgets/actions/thunder_action_chip.dart +++ b/lib/packages/ui/src/widgets/actions/thunder_action_chip.dart @@ -1,47 +1,84 @@ -import 'package:flutter/material.dart'; - -/// A custom action chip widget that extends the default ActionChip widget. -class ThunderActionChip extends StatelessWidget { - /// The icon to display in the action chip. - final IconData? icon; - - /// The trailing icon to display in the action chip. - final IconData? trailingIcon; - - /// The size of the trailing icon. - final double? trailingIconSize; - - /// The label of the action chip. - final String label; - - /// The background color of the action chip. - final Color? backgroundColor; - - /// The function to call when the action chip is pressed. - final void Function()? onPressed; - - const ThunderActionChip({super.key, this.icon, this.trailingIcon, this.trailingIconSize, required this.label, this.onPressed, this.backgroundColor}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - Widget child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[Icon(icon, size: 15.0), const SizedBox(width: 5.0)], - Text(label), - if (trailingIcon != null) ...[const SizedBox(width: 5.0), Icon(trailingIcon, size: trailingIconSize ?? 20.0)], - ], - ); - - return ActionChip( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - side: BorderSide(color: theme.dividerColor), - backgroundColor: backgroundColor, - label: child, - onPressed: onPressed, - ); - } -} +import 'package:flutter/material.dart'; + +/// A custom action chip that wraps Material [ActionChip]. +@immutable +class ThunderActionChip extends StatelessWidget { + const ThunderActionChip({ + super.key, + this.icon, + this.trailingIcon, + this.trailingIconSize, + required this.label, + this.labelWidget, + this.onPressed, + this.backgroundColor, + }); + + /// The icon to display in the action chip. + final IconData? icon; + + /// The trailing icon to display in the action chip. + final IconData? trailingIcon; + + /// The size of the trailing icon. + final double? trailingIconSize; + + /// The label of the action chip. + final String label; + + /// Optional custom label widget; when set, replaces the default [Text] label. + final Widget? labelWidget; + + /// The background color of the action chip. + final Color? backgroundColor; + + /// The function to call when the action chip is pressed. + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ActionChip( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + side: BorderSide(color: theme.dividerColor), + backgroundColor: backgroundColor, + label: labelWidget ?? _ThunderActionChipLabel(icon: icon, trailingIcon: trailingIcon, trailingIconSize: trailingIconSize, label: label), + onPressed: onPressed, + ); + } +} + +class _ThunderActionChipLabel extends StatelessWidget { + const _ThunderActionChipLabel({ + this.icon, + this.trailingIcon, + this.trailingIconSize, + required this.label, + }); + + /// The icon to display in the action chip. + final IconData? icon; + + /// The trailing icon to display in the action chip. + final IconData? trailingIcon; + + /// The size of the trailing icon. + final double? trailingIconSize; + + /// The label of the action chip. + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[Icon(icon, size: 15.0), const SizedBox(width: 5.0)], + Text(label), + if (trailingIcon != null) ...[const SizedBox(width: 5.0), Icon(trailingIcon, size: trailingIconSize ?? 20.0)], + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/actions/bottom_sheet_action.dart b/lib/packages/ui/src/widgets/actions/thunder_bottom_sheet_action.dart similarity index 56% rename from lib/packages/ui/src/widgets/actions/bottom_sheet_action.dart rename to lib/packages/ui/src/widgets/actions/thunder_bottom_sheet_action.dart index 8b7faefba..1244d0840 100644 --- a/lib/packages/ui/src/widgets/actions/bottom_sheet_action.dart +++ b/lib/packages/ui/src/widgets/actions/thunder_bottom_sheet_action.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; -/// Defines a widget that can be used in a [BottomSheet]. Can provide optional [leading] and [trailing] widgets. +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Defines an action tile that can be used in a [BottomSheet]. /// -/// When tapped, will call the [onTap] callback. -class BottomSheetAction extends StatelessWidget { - const BottomSheetAction({ +/// Can provide optional [leading] and [trailing] widgets. When tapped, calls [onTap]. +@immutable +class ThunderBottomSheetAction extends StatelessWidget { + const ThunderBottomSheetAction({ super.key, required this.leading, this.trailing, @@ -14,27 +17,28 @@ class BottomSheetAction extends StatelessWidget { this.onLongPress, }); - /// The leading widget + /// The leading widget. final Widget leading; - /// The trailing widget + /// The trailing widget. final Widget? trailing; - /// The title of the category + /// The title of the action. final String title; - /// The subtitle of the category + /// The subtitle of the action. final String? subtitle; - /// Callback function to be called when the category is tapped - final Function() onTap; + /// Called when the action is tapped. + final void Function() onTap; - /// Callback function to be called when the category is long pressed - final Function()? onLongPress; + /// Called when the action is long-pressed. + final void Function()? onLongPress; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); return InkWell( onTap: onTap, @@ -47,7 +51,7 @@ class BottomSheetAction extends StatelessWidget { subtitle: subtitle != null ? Text( subtitle ?? '', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.8)), + style: theme.textTheme.bodyMedium?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: thunderTheme.settingsTileSubtitleAlpha)), maxLines: 1, overflow: TextOverflow.ellipsis, ) diff --git a/lib/packages/ui/src/widgets/actions/thunder_multi_action_dismissible.dart b/lib/packages/ui/src/widgets/actions/thunder_multi_action_dismissible.dart index b5c324c98..3af50a0f7 100644 --- a/lib/packages/ui/src/widgets/actions/thunder_multi_action_dismissible.dart +++ b/lib/packages/ui/src/widgets/actions/thunder_multi_action_dismissible.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:thunder/packages/ui/src/widgets/actions/thunder_swipe_action_background.dart'; + +/// Builds the swipe background for [ThunderMultiActionDismissible]. typedef ThunderSwipeBackgroundBuilder = Widget Function( BuildContext context, DismissDirection effectiveDirection, @@ -8,6 +11,8 @@ typedef ThunderSwipeBackgroundBuilder = Widget Function( ThunderSwipeAction? action, ); +/// Swipe action definition with icon and dynamic background color. +@immutable class ThunderSwipeAction { const ThunderSwipeAction({ required this.value, @@ -15,11 +20,17 @@ class ThunderSwipeAction { required this.color, }); + /// Payload returned when this action is triggered. final T value; + + /// Optional icon shown in the swipe background. final IconData? icon; + + /// Resolves the background color for this action. final Color Function(BuildContext context) color; } +/// Dismissible wrapper that reveals tiered swipe actions without dismissing. class ThunderMultiActionDismissible extends StatefulWidget { const ThunderMultiActionDismissible({ super.key, @@ -38,18 +49,43 @@ class ThunderMultiActionDismissible extends StatefulWidget { this.backgroundMaxWidthFactor = 1.0, }); + /// Content revealed while swiping. final Widget child; + + /// Allowed swipe direction for revealing actions. final DismissDirection direction; + + /// Progress thresholds that map to action tiers. final List actionThresholds; + + /// Actions revealed when swiping start to end. final List> leftActions; + + /// Actions revealed when swiping end to start. final List> rightActions; + + /// Called when a swipe action tier is committed on pointer up. final void Function(ThunderSwipeAction action)? onAction; + + /// Called as swipe progress or the active action changes. final void Function(double progress, DismissDirection direction, ThunderSwipeAction? action)? onProgressChanged; - final VoidCallback? onPointerDown; + + /// Called when a pointer goes down on the dismissible area. + final void Function()? onPointerDown; + + /// Called on pointer up with the last vertical drag delta. final void Function(double verticalDelta)? onDragEnd; + + /// Whether to emit haptic feedback when the active action changes. final bool enableHaptics; + + /// Whether a right swipe can temporarily disable end-to-start dismissal. final bool enableBackSwipeOverride; + + /// Optional custom background builder. Defaults to a themed color fill. final ThunderSwipeBackgroundBuilder? backgroundBuilder; + + /// Maximum background width as a fraction of screen width. final double backgroundMaxWidthFactor; @override @@ -57,12 +93,20 @@ class ThunderMultiActionDismissible extends StatefulWidget { } class _ThunderMultiActionDismissibleState extends State> { + late final Key _dismissibleKey; + double _progress = 0; ThunderSwipeAction? _currentAction; DismissDirection _currentDirection = DismissDirection.startToEnd; bool _overrideSwipe = false; double _lastVerticalDelta = 0; + @override + void initState() { + super.initState(); + _dismissibleKey = widget.key ?? ValueKey(identityHashCode(this)); + } + void _handlePointerMove(PointerMoveEvent event) { _lastVerticalDelta = event.delta.dy; @@ -124,29 +168,6 @@ class _ThunderMultiActionDismissibleState extends State extends State extends State false, onUpdate: _onUpdate, - background: widget.backgroundBuilder?.call(context, _currentDirection, _progress, _currentAction) ?? _buildDefaultBackground(context), + background: widget.backgroundBuilder?.call(context, _currentDirection, _progress, _currentAction) ?? + _ThunderSwipeDefaultBackground( + currentDirection: _currentDirection, + progress: _progress, + currentAction: _currentAction, + leftActions: widget.leftActions, + rightActions: widget.rightActions, + actionThresholds: widget.actionThresholds, + backgroundMaxWidthFactor: widget.backgroundMaxWidthFactor, + ), child: widget.child, ); } @@ -177,3 +207,57 @@ class _ThunderMultiActionDismissibleState extends State extends StatelessWidget { + const _ThunderSwipeDefaultBackground({ + required this.currentDirection, + required this.progress, + required this.currentAction, + required this.leftActions, + required this.rightActions, + required this.actionThresholds, + required this.backgroundMaxWidthFactor, + }); + + /// The current swipe direction. + final DismissDirection currentDirection; + + /// The current swipe progress. + final double progress; + + /// The currently active swipe action, if any. + final ThunderSwipeAction? currentAction; + + /// Actions revealed when swiping start to end. + final List> leftActions; + + /// Actions revealed when swiping end to start. + final List> rightActions; + + /// Progress thresholds that map to action tiers. + final List actionThresholds; + + /// Maximum background width as a fraction of screen width. + final double backgroundMaxWidthFactor; + + @override + Widget build(BuildContext context) { + final alignment = currentDirection == DismissDirection.startToEnd ? Alignment.centerLeft : Alignment.centerRight; + final actions = currentDirection == DismissDirection.startToEnd ? leftActions : rightActions; + final fallback = actions.isNotEmpty ? actions.first : null; + + final leadingThreshold = actionThresholds.isNotEmpty ? actionThresholds.first : 1.0; + final defaultColor = fallback?.color(context) ?? Theme.of(context).colorScheme.primaryContainer; + final backgroundColor = currentAction != null ? currentAction!.color(context) : defaultColor.withValues(alpha: leadingThreshold == 0 ? 0 : (progress / leadingThreshold).clamp(0.0, 1.0)); + + final width = MediaQuery.sizeOf(context).width * backgroundMaxWidthFactor * progress; + + return ThunderSwipeActionBackground( + alignment: alignment, + backgroundColor: backgroundColor, + width: width, + icon: currentAction?.icon, + ); + } +} diff --git a/lib/packages/ui/src/widgets/actions/thunder_popup_menu_item.dart b/lib/packages/ui/src/widgets/actions/thunder_popup_menu_item.dart index 01faeceac..c314f4770 100644 --- a/lib/packages/ui/src/widgets/actions/thunder_popup_menu_item.dart +++ b/lib/packages/ui/src/widgets/actions/thunder_popup_menu_item.dart @@ -1,23 +1,21 @@ import 'package:flutter/material.dart'; -/// Defines a custom [PopupMenuItem] that can be used throughout the app +/// Standardized popup menu item with a leading icon and optional trailing widget. class ThunderPopupMenuItem extends PopupMenuItem { - final IconData icon; - final String title; - final Widget? trailing; - + /// Creates a [PopupMenuItem] with Thunder list-tile styling. ThunderPopupMenuItem({ super.key, super.value, - required super.onTap, - required this.icon, - required this.title, - this.trailing, + required void Function() onTap, + required IconData icon, + required String title, + Widget? trailing, }) : super( + onTap: onTap, child: ListTile( dense: true, - horizontalTitleGap: 5, - leading: Icon(icon, size: 20), + horizontalTitleGap: 5.0, + leading: Icon(icon, size: 20.0), title: Text(title), trailing: trailing, ), diff --git a/lib/packages/ui/src/widgets/actions/thunder_preview_action_row.dart b/lib/packages/ui/src/widgets/actions/thunder_preview_action_row.dart new file mode 100644 index 000000000..c806f0e0c --- /dev/null +++ b/lib/packages/ui/src/widgets/actions/thunder_preview_action_row.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:thunder/packages/ui/src/widgets/common/thunder_icon_label.dart'; +import 'package:thunder/packages/ui/src/widgets/feedback/thunder_snackbar.dart'; +import 'package:thunder/packages/ui/src/widgets/layout/thunder_divider.dart'; + +/// Icon-label action row for reply preview toolbars. +@immutable +class ThunderPreviewActionRow extends StatelessWidget { + const ThunderPreviewActionRow({ + super.key, + required this.text, + required this.viewSource, + required this.onViewSourceToggled, + required this.viewSourceLabel, + required this.viewOriginalLabel, + required this.copyLabel, + required this.copiedMessage, + }); + + /// Content text copied when the copy action is tapped. + final String text; + + /// Whether source view mode is currently active. + final bool viewSource; + + /// Called when the view-source toggle is tapped. + final void Function()? onViewSourceToggled; + + /// Label shown when [viewSource] is false. + final String viewSourceLabel; + + /// Label shown when [viewSource] is true. + final String viewOriginalLabel; + + /// Label for the copy action. + final String copyLabel; + + /// Snackbar message shown after copying [text]. + final String copiedMessage; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), + child: Material( + color: Colors.transparent, + child: Column( + children: [ + const ThunderDivider(sliver: false, padding: false), + Row( + spacing: 12.0, + children: [ + _ThunderPreviewActionButton( + onTap: onViewSourceToggled, + icon: const Icon(Icons.edit_document, size: 15.0), + label: viewSource ? viewOriginalLabel : viewSourceLabel, + ), + _ThunderPreviewActionButton( + onTap: () async { + await Clipboard.setData(ClipboardData(text: text)); + showThunderSnackbar(copiedMessage); + }, + icon: const Icon(Icons.copy_rounded, size: 15.0), + label: copyLabel, + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Icon-label button for a single preview toolbar action. +class _ThunderPreviewActionButton extends StatelessWidget { + const _ThunderPreviewActionButton({ + required this.onTap, + required this.icon, + required this.label, + }); + + /// Called when the button is tapped. + final void Function()? onTap; + + /// Icon widget shown before the label. + final Widget icon; + + /// Button label text. + final String label; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(10.0), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), + child: ThunderIconLabel(gap: 5.0, icon: icon, label: label), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/actions/thunder_swipe_action_background.dart b/lib/packages/ui/src/widgets/actions/thunder_swipe_action_background.dart new file mode 100644 index 000000000..134e4a5fc --- /dev/null +++ b/lib/packages/ui/src/widgets/actions/thunder_swipe_action_background.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +/// Animated background revealed during swipe actions on list cards. +@immutable +class ThunderSwipeActionBackground extends StatelessWidget { + const ThunderSwipeActionBackground({ + super.key, + required this.alignment, + required this.backgroundColor, + required this.width, + this.icon, + this.duration = const Duration(milliseconds: 200), + }); + + /// Horizontal alignment of the background content. + final Alignment alignment; + + /// Fill color revealed during the swipe. + final Color backgroundColor; + + /// Width of the revealed background area. + final double width; + + /// Optional icon centered in the background. + final IconData? icon; + + /// Animation duration when the width changes. + final Duration duration; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + alignment: alignment, + duration: duration, + color: backgroundColor, + child: SizedBox( + width: width, + child: icon != null ? Icon(icon) : const SizedBox.shrink(), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/avatar/models/avatar_data.dart b/lib/packages/ui/src/widgets/avatar/models/thunder_avatar_data.dart similarity index 70% rename from lib/packages/ui/src/widgets/avatar/models/avatar_data.dart rename to lib/packages/ui/src/widgets/avatar/models/thunder_avatar_data.dart index a5c968a9a..1d8f8f590 100644 --- a/lib/packages/ui/src/widgets/avatar/models/avatar_data.dart +++ b/lib/packages/ui/src/widgets/avatar/models/thunder_avatar_data.dart @@ -1,5 +1,9 @@ -class AvatarData { - const AvatarData({ +import 'package:flutter/foundation.dart'; + +/// Immutable configuration for [ThunderAvatar]. +@immutable +class ThunderAvatarData { + const ThunderAvatarData({ this.imageUrl, this.radius = 16.0, this.fallbackLabel, diff --git a/lib/packages/ui/src/widgets/avatar/avatar.dart b/lib/packages/ui/src/widgets/avatar/thunder_avatar.dart similarity index 68% rename from lib/packages/ui/src/widgets/avatar/avatar.dart rename to lib/packages/ui/src/widgets/avatar/thunder_avatar.dart index f72a840cb..6ba059119 100644 --- a/lib/packages/ui/src/widgets/avatar/avatar.dart +++ b/lib/packages/ui/src/widgets/avatar/thunder_avatar.dart @@ -2,19 +2,24 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:thunder/packages/ui/src/widgets/avatar/models/avatar_data.dart'; +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; +import 'package:thunder/packages/ui/src/widgets/avatar/models/thunder_avatar_data.dart'; -class Avatar extends StatelessWidget { - const Avatar({ +/// Circular avatar that loads a network image with a letter fallback. +@immutable +class ThunderAvatar extends StatelessWidget { + const ThunderAvatar({ super.key, required this.data, }); - final AvatarData data; + /// Avatar image URL, radius, and fallback configuration. + final ThunderAvatarData data; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); final fallbackText = data.fallbackLabel?.isNotEmpty == true ? data.fallbackLabel![0].toUpperCase() : ''; final placeholder = CircleAvatar( @@ -34,7 +39,7 @@ class Avatar extends StatelessWidget { imageUrl: imageUrl!, imageBuilder: (context, imageProvider) { return CircleAvatar( - backgroundColor: Colors.transparent, + backgroundColor: thunderTheme.avatarImageBackgroundColor, foregroundImage: imageProvider, maxRadius: data.radius, ); diff --git a/lib/packages/ui/src/widgets/common/thunder_empty_text.dart b/lib/packages/ui/src/widgets/common/thunder_empty_text.dart new file mode 100644 index 000000000..a19fc9bed --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_empty_text.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Italic empty-state text, typically used inside a sliver list. +@immutable +class ThunderEmptyText extends StatelessWidget { + const ThunderEmptyText({ + super.key, + required this.message, + this.padding = const EdgeInsets.only(left: 24.0, bottom: 16.0), + }); + + /// Empty-state message text. + final String message; + + /// Outer padding around the text. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + + return Padding( + padding: padding, + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: theme.textTheme.bodyMedium?.color?.withValues(alpha: thunderTheme.mutedTextAlpha), + ), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/common/thunder_error_state.dart b/lib/packages/ui/src/widgets/common/thunder_error_state.dart deleted file mode 100644 index 740f8d97e..000000000 --- a/lib/packages/ui/src/widgets/common/thunder_error_state.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; - -class ThunderErrorAction { - const ThunderErrorAction({ - required this.label, - required this.onPressed, - this.loading = false, - this.primary = false, - }); - - final String label; - final VoidCallback onPressed; - final bool loading; - final bool primary; -} - -class ThunderErrorState extends StatelessWidget { - const ThunderErrorState({ - super.key, - this.title, - this.message, - this.actions = const [], - this.icon, - }); - - final String? title; - final String? message; - final List actions; - final IconData? icon; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Center( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(icon ?? Icons.warning_rounded, size: 100, color: theme.colorScheme.error), - const SizedBox(height: 32), - Text( - title ?? 'Something went wrong', - style: theme.textTheme.titleLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - message ?? 'An unexpected error occurred.', - style: theme.textTheme.labelLarge?.copyWith(color: theme.dividerColor), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - if (actions.isNotEmpty) - Column( - children: [ - for (int i = 0; i < actions.length; i++) ...[ - SizedBox( - width: double.infinity, - child: actions[i].primary || i == 0 - ? ElevatedButton( - onPressed: actions[i].loading ? null : actions[i].onPressed, - child: actions[i].loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator()) : Text(actions[i].label), - ) - : TextButton( - onPressed: actions[i].loading ? null : actions[i].onPressed, - child: actions[i].loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator()) : Text(actions[i].label), - ), - ), - if (i != actions.length - 1) const SizedBox(height: 12), - ], - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/packages/ui/src/widgets/common/thunder_icon_label.dart b/lib/packages/ui/src/widgets/common/thunder_icon_label.dart index 1adce7931..39038305a 100644 --- a/lib/packages/ui/src/widgets/common/thunder_icon_label.dart +++ b/lib/packages/ui/src/widgets/common/thunder_icon_label.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:thunder/packages/ui/src/widgets/identity/scalable_text.dart'; +import 'package:thunder/packages/ui/src/widgets/identity/thunder_scalable_text.dart'; +/// Horizontally lays out an icon with an optional label. +@immutable class ThunderIconLabel extends StatelessWidget { const ThunderIconLabel({ super.key, @@ -10,16 +12,29 @@ class ThunderIconLabel extends StatelessWidget { this.labelStyle, this.textScaleFactor = 1.0, this.semanticsLabel, - this.gap = 4, + this.gap = 4.0, this.mainAxisSize = MainAxisSize.min, }); + /// The icon widget displayed at the start of the row. final Widget icon; + + /// Optional label shown beside [icon]. When null or empty, only [icon] is returned. final String? label; + + /// Style applied to [label]. Defaults to [ThemeData.textTheme.bodyMedium]. final TextStyle? labelStyle; + + /// Additional scale factor applied to the label font size. final double textScaleFactor; + + /// Semantic label for accessibility on the label text. final String? semanticsLabel; + + /// Horizontal spacing between [icon] and [label]. final double gap; + + /// How the row sizes itself along the main axis. final MainAxisSize mainAxisSize; @override @@ -34,7 +49,7 @@ class ThunderIconLabel extends StatelessWidget { children: [ icon, SizedBox(width: gap), - ScalableText( + ThunderScalableText( label!, style: labelStyle ?? theme.textTheme.bodyMedium, textScaleFactor: textScaleFactor, diff --git a/lib/packages/ui/src/widgets/common/thunder_skeleton_bar.dart b/lib/packages/ui/src/widgets/common/thunder_skeleton_bar.dart new file mode 100644 index 000000000..054ea8dec --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_skeleton_bar.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +/// A single skeleton placeholder bar. +@immutable +class ThunderSkeletonBar extends StatelessWidget { + const ThunderSkeletonBar({ + super.key, + required this.width, + this.height = 10.0, + this.opacity = 0.25, + this.padding = EdgeInsets.zero, + }); + + /// Bar width. + final double width; + + /// Bar height. + final double height; + + /// Opacity applied to the bar fill color. + final double opacity; + + /// Padding around the bar. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: padding, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: theme.hintColor.withValues(alpha: opacity), + borderRadius: const BorderRadius.all(Radius.elliptical(5.0, 5.0)), + ), + ), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/common/thunder_skeleton_placeholder.dart b/lib/packages/ui/src/widgets/common/thunder_skeleton_placeholder.dart new file mode 100644 index 000000000..cb6b61feb --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_skeleton_placeholder.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/common/thunder_skeleton_bar.dart'; + +/// Configuration for a single bar in [ThunderSkeletonPlaceholder]. +@immutable +class ThunderSkeletonBarSpec { + const ThunderSkeletonBarSpec({ + required this.width, + this.height = 10.0, + this.opacity = 0.25, + this.padding = EdgeInsets.zero, + }); + + /// Bar width. + final double width; + + /// Bar height. + final double height; + + /// Opacity applied to the bar fill color. + final double opacity; + + /// Padding around the bar. + final EdgeInsetsGeometry padding; +} + +/// Composable skeleton placeholder built from [ThunderSkeletonBar] widgets. +@immutable +class ThunderSkeletonPlaceholder extends StatelessWidget { + const ThunderSkeletonPlaceholder({super.key, required this.bars}); + + /// Post card skeleton with three placeholder bars. + const ThunderSkeletonPlaceholder.post({super.key}) + : bars = const [ + ThunderSkeletonBarSpec(width: 100.0, opacity: 0.25, padding: EdgeInsets.fromLTRB(10.0, 10.0, 0.0, 2.0)), + ThunderSkeletonBarSpec(width: 75.0, opacity: 0.1, padding: EdgeInsets.fromLTRB(10.0, 2.0, 0.0, 2.0)), + ThunderSkeletonBarSpec(width: 75.0, opacity: 0.1, padding: EdgeInsets.fromLTRB(10.0, 2.0, 0.0, 10.0)), + ]; + + /// Skeleton bar specifications rendered top to bottom. + final List bars; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final bar in bars) + ThunderSkeletonBar( + width: bar.width, + height: bar.height, + opacity: bar.opacity, + padding: bar.padding, + ), + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/common/thunder_state_action.dart b/lib/packages/ui/src/widgets/common/thunder_state_action.dart new file mode 100644 index 000000000..49296125c --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_state_action.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +/// Action button configuration for [ThunderStateView]. +@immutable +class ThunderStateAction { + const ThunderStateAction({ + required this.label, + required this.onPressed, + this.loading = false, + this.primary = false, + }); + + /// Button label text. + final String label; + + /// Called when the action button is pressed. + final void Function() onPressed; + + /// When true, shows a progress indicator and disables the button. + final bool loading; + + /// When true, renders as an elevated button instead of a text button. + final bool primary; +} diff --git a/lib/packages/ui/src/widgets/common/thunder_state_actions.dart b/lib/packages/ui/src/widgets/common/thunder_state_actions.dart new file mode 100644 index 000000000..9397a0ce6 --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_state_actions.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/common/thunder_state_action.dart'; + +/// Action buttons for Thunder state views. +@immutable +class ThunderStateActions extends StatelessWidget { + const ThunderStateActions({ + super.key, + required this.actions, + }); + + /// Action buttons rendered in a vertical column. + final List actions; + + @override + Widget build(BuildContext context) { + if (actions.isEmpty) return const SizedBox.shrink(); + + return Column( + children: [ + for (int i = 0; i < actions.length; i++) ...[ + SizedBox( + width: double.infinity, + child: _ThunderStateActionButton( + action: actions[i], + primary: actions[i].primary || i == 0, + ), + ), + if (i != actions.length - 1) const SizedBox(height: 12.0), + ], + ], + ); + } +} + +/// Single action button for [ThunderStateActions]. +class _ThunderStateActionButton extends StatelessWidget { + const _ThunderStateActionButton({ + required this.action, + required this.primary, + }); + + /// The action configuration to render. + final ThunderStateAction action; + + /// Whether to render as a primary elevated button. + final bool primary; + + @override + Widget build(BuildContext context) { + final child = action.loading ? const SizedBox(width: 20.0, height: 20.0, child: CircularProgressIndicator()) : Text(action.label); + + if (primary) { + return ElevatedButton( + onPressed: action.loading ? null : action.onPressed, + child: child, + ); + } + + return TextButton( + onPressed: action.loading ? null : action.onPressed, + child: child, + ); + } +} diff --git a/lib/packages/ui/src/widgets/common/thunder_state_icon.dart b/lib/packages/ui/src/widgets/common/thunder_state_icon.dart new file mode 100644 index 000000000..41cab2147 --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_state_icon.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Icon shown in Thunder loading/error/empty states. +@immutable +class ThunderStateIcon extends StatelessWidget { + const ThunderStateIcon({ + super.key, + required this.icon, + this.compact = false, + this.color, + }); + + /// Icon to display. + final IconData icon; + + /// When true, uses the compact icon size. + final bool compact; + + /// Icon color. Defaults to [ColorScheme.error]. + final Color? color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + final size = compact ? thunderTheme.stateIconSizeCompact : thunderTheme.stateIconSizeLarge; + + return Icon( + icon, + size: size, + color: color ?? theme.colorScheme.error, + ); + } +} diff --git a/lib/packages/ui/src/widgets/common/thunder_state_text.dart b/lib/packages/ui/src/widgets/common/thunder_state_text.dart new file mode 100644 index 000000000..5c48894a4 --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_state_text.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Title and message text for Thunder state views. +@immutable +class ThunderStateText extends StatelessWidget { + const ThunderStateText({ + super.key, + this.title, + this.message, + this.titleStyle, + this.messageStyle, + this.italic = false, + }); + + /// Optional title text. + final String? title; + + /// Optional message text shown below [title]. + final String? message; + + /// Custom style for [title]. + final TextStyle? titleStyle; + + /// Custom style for [message]. + final TextStyle? messageStyle; + + /// When true, renders [message] in italics. + final bool italic; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + final mutedMessageStyle = messageStyle ?? + theme.textTheme.labelLarge?.copyWith( + color: theme.dividerColor.withValues(alpha: thunderTheme.mutedTextAlpha), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title!, + style: titleStyle ?? theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + if (message != null) const SizedBox(height: 8.0), + ], + if (message != null) + Text( + message!, + style: mutedMessageStyle?.copyWith( + fontStyle: italic ? FontStyle.italic : null, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/common/thunder_state_view.dart b/lib/packages/ui/src/widgets/common/thunder_state_view.dart new file mode 100644 index 000000000..0db339c5b --- /dev/null +++ b/lib/packages/ui/src/widgets/common/thunder_state_view.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/common/thunder_state_action.dart'; +import 'package:thunder/packages/ui/src/widgets/common/thunder_state_actions.dart'; +import 'package:thunder/packages/ui/src/widgets/common/thunder_state_icon.dart'; +import 'package:thunder/packages/ui/src/widgets/common/thunder_state_text.dart'; +import 'package:thunder/packages/ui/src/widgets/layout/thunder_sliver_adapter.dart'; + +/// Presentation mode for [ThunderStateView]. +enum ThunderStateViewMode { + /// Shows a centered progress indicator. + loading, + + /// Shows an error icon, title, message, and optional actions. + error, + + /// Shows italicized empty-state text. + empty, + + /// Shows a custom [ThunderStateView.child] widget. + custom, +} + +/// Composable loading, error, empty, or custom state presentation. +@immutable +class ThunderStateView extends StatelessWidget { + const ThunderStateView({ + super.key, + this.mode = ThunderStateViewMode.error, + this.title, + this.message, + this.actions = const [], + this.icon, + this.compact = false, + this.sliver = false, + this.fillRemaining = true, + this.hasScrollBody = false, + this.padding = const EdgeInsets.all(12.0), + this.child, + this.semanticsLabel, + }); + + /// Loading state with a centered progress indicator. + /// + /// Omits error/empty-specific fields such as [title], [message], [actions], + /// [icon], [compact], and [child]. + const ThunderStateView.loading({ + super.key, + this.sliver = false, + this.fillRemaining = true, + this.hasScrollBody = false, + this.padding = const EdgeInsets.all(12.0), + this.semanticsLabel, + }) : mode = ThunderStateViewMode.loading, + title = null, + message = null, + actions = const [], + icon = null, + compact = false, + child = null; + + /// Presentation mode for this state view. + final ThunderStateViewMode mode; + + /// Optional title text for error and empty modes. + final String? title; + + /// Optional message text shown below [title]. + final String? message; + + /// Action buttons shown in error mode. + final List actions; + + /// Custom icon for error mode. Defaults to a warning icon. + final IconData? icon; + + /// When true, uses tighter spacing in error mode. + final bool compact; + + /// When true, wraps content in a sliver adapter. + final bool sliver; + + /// When [sliver] is true, uses [SliverFillRemaining]. + final bool fillRemaining; + + /// Passed to [SliverFillRemaining] when [fillRemaining] is true. + final bool hasScrollBody; + + /// Outer padding around the state content. + final EdgeInsetsGeometry padding; + + /// Custom content shown in custom mode. + final Widget? child; + + /// Accessibility label for loading mode. + final String? semanticsLabel; + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: padding, + child: Center( + child: _ThunderStateViewBody( + mode: mode, + title: title, + message: message, + actions: actions, + icon: icon, + compact: compact, + semanticsLabel: semanticsLabel, + child: child, + ), + ), + ); + + return ThunderSliverAdapter( + sliver: sliver, + fillRemaining: fillRemaining, + hasScrollBody: hasScrollBody, + child: content, + ); + } +} + +/// Inner content builder for [ThunderStateView] modes. +class _ThunderStateViewBody extends StatelessWidget { + const _ThunderStateViewBody({ + required this.mode, + this.title, + this.message, + required this.actions, + this.icon, + required this.compact, + this.child, + this.semanticsLabel, + }); + + /// The presentation mode for this state view. + final ThunderStateViewMode mode; + + /// Optional title text for error and empty modes. + final String? title; + + /// Optional message text shown below [title]. + final String? message; + + /// Action buttons shown in error mode. + final List actions; + + /// Custom icon for error mode. + final IconData? icon; + + /// When true, uses tighter spacing in error mode. + final bool compact; + + /// Custom content shown in custom mode. + final Widget? child; + + /// Accessibility label for loading mode. + final String? semanticsLabel; + + @override + Widget build(BuildContext context) { + switch (mode) { + case ThunderStateViewMode.loading: + return Semantics( + label: semanticsLabel, + child: const CircularProgressIndicator(), + ); + case ThunderStateViewMode.custom: + return child ?? const SizedBox.shrink(); + case ThunderStateViewMode.empty: + return ThunderStateText( + title: title, + message: message, + italic: true, + ); + case ThunderStateViewMode.error: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ThunderStateIcon( + icon: icon ?? Icons.warning_rounded, + compact: compact, + ), + SizedBox(height: compact ? 16.0 : 32.0), + ThunderStateText( + title: title, + message: message, + ), + if (actions.isNotEmpty) ...[ + SizedBox(height: compact ? 16.0 : 32.0), + ThunderStateActions(actions: actions), + ], + ], + ); + } + } +} diff --git a/lib/packages/ui/src/widgets/dialogs/thunder_dialog.dart b/lib/packages/ui/src/widgets/dialogs/thunder_dialog.dart index 16a723316..747ca7cfd 100644 --- a/lib/packages/ui/src/widgets/dialogs/thunder_dialog.dart +++ b/lib/packages/ui/src/widgets/dialogs/thunder_dialog.dart @@ -1,85 +1,194 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -Future showThunderDialog({ - required BuildContext context, - required String title, - String? contentText, - // This allows the caller to provide a custom build function for the content widget. - // We also give them a callback to set the enabled state of the primary button. - Widget Function(void Function(bool) setPrimaryButtonEnabled)? contentWidgetBuilder, - String? primaryButtonText, - String? secondaryButtonText, - // This is the function that we call when the primary button is pressed. - // We also give the caller a callback to set the enabled state of the primary button. - void Function(BuildContext dialogContext, void Function(bool) setPrimaryButtonEnabled)? onPrimaryButtonPressed, - void Function(BuildContext dialogContext)? onSecondaryButtonPressed, - // This is a builder which lets the caller wrap the AlertDialog (which we generate here) - // with any other widget of their choosing (e.g., Bloc-related things). - Widget Function(Widget alertDialog)? customBuilder, - bool? primaryButtonInitialEnabled, - String? tertiaryButtonText, - void Function(BuildContext dialogContext)? onTertiaryButtonPressed, -}) { - // Assert that we have text or widget, but not both - assert((contentText != null || contentWidgetBuilder != null) && !(contentText != null && contentWidgetBuilder != null)); - - // This is a function that generates our AlertDialog. - // We can call it directory or pass it as an argument to the caller's custom builder. - // It is stateful so that we can change the state of the primary button. - Widget generateAlertDialogForThunderDialog() { - bool primaryButtonEnabled = primaryButtonInitialEnabled ?? true; - return StatefulBuilder( - builder: (context, setState) => AlertDialog( - title: Text(title), - content: SizedBox( - width: min(MediaQuery.of(context).size.width, 700), - child: contentText != null - ? Text(contentText) - : contentWidgetBuilder!( - (enabled) => setState(() => primaryButtonEnabled = enabled), - ), - ), - actions: [ - Row( - mainAxisSize: MainAxisSize.max, - children: [ - if (tertiaryButtonText != null) ...[ - TextButton( - onPressed: onTertiaryButtonPressed == null ? null : () => onTertiaryButtonPressed(context), - child: Text(tertiaryButtonText), - ), - ], - const Spacer(), - if (secondaryButtonText != null) ...[ - TextButton( - onPressed: onSecondaryButtonPressed == null ? null : () => onSecondaryButtonPressed(context), - child: Text(secondaryButtonText), - ), - const SizedBox(width: 5), - ], - if (primaryButtonText != null) - FilledButton( - onPressed: !primaryButtonEnabled || onPrimaryButtonPressed == null - ? null - : () => onPrimaryButtonPressed( - context, - (enabled) => setState(() => primaryButtonEnabled = enabled), - ), - child: Text(primaryButtonText), - ), - ], - ), - ], - ), - ); - } - - return showDialog( - context: context, - builder: (BuildContext context) { - return customBuilder != null ? customBuilder(generateAlertDialogForThunderDialog()) : generateAlertDialogForThunderDialog(); - }, - ); -} +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// Alert dialog with primary/secondary/tertiary actions and optional custom content. +class ThunderDialog extends StatefulWidget { + const ThunderDialog({ + super.key, + required this.title, + this.contentText, + this.contentWidgetBuilder, + this.primaryButtonText, + this.secondaryButtonText, + this.onPrimaryButtonPressed, + this.onSecondaryButtonPressed, + this.primaryButtonInitialEnabled, + this.tertiaryButtonText, + this.onTertiaryButtonPressed, + }); + + /// Dialog title text. + final String title; + + /// Plain-text dialog body. Mutually exclusive with [contentWidgetBuilder]. + final String? contentText; + + /// Custom dialog body widget. Receives [setPrimaryButtonEnabled]. + final Widget Function(void Function(bool) setPrimaryButtonEnabled)? contentWidgetBuilder; + + /// Label for the primary filled action button. + final String? primaryButtonText; + + /// Label for the secondary text action button. + final String? secondaryButtonText; + + /// Called when the primary button is pressed. + final void Function(BuildContext dialogContext, void Function(bool) setPrimaryButtonEnabled)? onPrimaryButtonPressed; + + /// Called when the secondary button is pressed. + final void Function(BuildContext dialogContext)? onSecondaryButtonPressed; + + /// Initial enabled state of the primary button. + final bool? primaryButtonInitialEnabled; + + /// Label for the leading tertiary text action button. + final String? tertiaryButtonText; + + /// Called when the tertiary button is pressed. + final void Function(BuildContext dialogContext)? onTertiaryButtonPressed; + + @override + State createState() => _ThunderDialogState(); +} + +class _ThunderDialogState extends State { + late bool _primaryButtonEnabled; + + @override + void initState() { + super.initState(); + _primaryButtonEnabled = widget.primaryButtonInitialEnabled ?? true; + } + + void _setPrimaryButtonEnabled(bool enabled) { + setState(() => _primaryButtonEnabled = enabled); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: min(MediaQuery.of(context).size.width, 700), + child: widget.contentText != null ? Text(widget.contentText!) : widget.contentWidgetBuilder!(_setPrimaryButtonEnabled), + ), + actions: [ + _ThunderDialogActions( + primaryButtonEnabled: _primaryButtonEnabled, + primaryButtonText: widget.primaryButtonText, + secondaryButtonText: widget.secondaryButtonText, + tertiaryButtonText: widget.tertiaryButtonText, + onPrimaryButtonPressed: widget.onPrimaryButtonPressed == null ? null : () => widget.onPrimaryButtonPressed!(context, _setPrimaryButtonEnabled), + onSecondaryButtonPressed: widget.onSecondaryButtonPressed == null ? null : () => widget.onSecondaryButtonPressed!(context), + onTertiaryButtonPressed: widget.onTertiaryButtonPressed == null ? null : () => widget.onTertiaryButtonPressed!(context), + ), + ], + ); + } +} + +/// Dialog action button row for [ThunderDialog]. +class _ThunderDialogActions extends StatelessWidget { + const _ThunderDialogActions({ + required this.primaryButtonEnabled, + this.primaryButtonText, + this.secondaryButtonText, + this.tertiaryButtonText, + this.onPrimaryButtonPressed, + this.onSecondaryButtonPressed, + this.onTertiaryButtonPressed, + }); + + /// Whether the primary button is enabled. + final bool primaryButtonEnabled; + + /// Label for the primary filled action button. + final String? primaryButtonText; + + /// Label for the secondary text action button. + final String? secondaryButtonText; + + /// Label for the leading tertiary text action button. + final String? tertiaryButtonText; + + /// Called when the primary button is pressed. + final void Function()? onPrimaryButtonPressed; + + /// Called when the secondary button is pressed. + final void Function()? onSecondaryButtonPressed; + + /// Called when the tertiary button is pressed. + final void Function()? onTertiaryButtonPressed; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + if (tertiaryButtonText != null) ...[ + TextButton( + onPressed: onTertiaryButtonPressed, + child: Text(tertiaryButtonText!), + ), + ], + const Spacer(), + if (secondaryButtonText != null) ...[ + TextButton( + onPressed: onSecondaryButtonPressed, + child: Text(secondaryButtonText!), + ), + const SizedBox(width: 5.0), + ], + if (primaryButtonText != null) + FilledButton( + onPressed: !primaryButtonEnabled || onPrimaryButtonPressed == null ? null : onPrimaryButtonPressed, + child: Text(primaryButtonText!), + ), + ], + ); + } +} + +/// Presents a [ThunderDialog] with optional custom content and action buttons. +/// +/// Either [contentText] or [contentWidgetBuilder] must be provided, but not both. +Future showThunderDialog({ + required BuildContext context, + required String title, + String? contentText, + Widget Function(void Function(bool) setPrimaryButtonEnabled)? contentWidgetBuilder, + String? primaryButtonText, + String? secondaryButtonText, + void Function(BuildContext dialogContext, void Function(bool) setPrimaryButtonEnabled)? onPrimaryButtonPressed, + void Function(BuildContext dialogContext)? onSecondaryButtonPressed, + Widget Function(Widget alertDialog)? customBuilder, + bool? primaryButtonInitialEnabled, + String? tertiaryButtonText, + void Function(BuildContext dialogContext)? onTertiaryButtonPressed, +}) { + assert((contentText != null || contentWidgetBuilder != null) && !(contentText != null && contentWidgetBuilder != null)); + + Widget buildDialog() { + return ThunderDialog( + title: title, + contentText: contentText, + contentWidgetBuilder: contentWidgetBuilder, + primaryButtonText: primaryButtonText, + secondaryButtonText: secondaryButtonText, + onPrimaryButtonPressed: onPrimaryButtonPressed, + onSecondaryButtonPressed: onSecondaryButtonPressed, + primaryButtonInitialEnabled: primaryButtonInitialEnabled, + tertiaryButtonText: tertiaryButtonText, + onTertiaryButtonPressed: onTertiaryButtonPressed, + ); + } + + return showDialog( + context: context, + builder: (BuildContext context) { + final dialog = buildDialog(); + return customBuilder != null ? customBuilder(dialog) : dialog; + }, + ); +} diff --git a/lib/packages/ui/src/widgets/dialogs/thunder_typeahead_dialog.dart b/lib/packages/ui/src/widgets/dialogs/thunder_typeahead_dialog.dart index 94f87947b..4a827d375 100644 --- a/lib/packages/ui/src/widgets/dialogs/thunder_typeahead_dialog.dart +++ b/lib/packages/ui/src/widgets/dialogs/thunder_typeahead_dialog.dart @@ -7,6 +7,9 @@ import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:thunder/packages/ui/src/widgets/dialogs/thunder_dialog.dart'; +/// Shows a typeahead dialog backed by [showThunderDialog]. +/// +/// The primary action stays disabled until the input field contains text. Future showThunderTypeaheadDialog({ required BuildContext context, required String title, @@ -17,9 +20,7 @@ Future showThunderTypeaheadDialog({ required FutureOr?> Function(String query) getSuggestions, required Widget Function(T payload) suggestionBuilder, }) async { - final textController = TextEditingController(); - StateSetter? contentWidgetSetState; - String? contentWidgetError; + final contentKey = GlobalKey<_ThunderTypeaheadDialogContentState>(); await showThunderDialog( context: context, @@ -29,50 +30,95 @@ Future showThunderTypeaheadDialog({ primaryButtonInitialEnabled: false, onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) async { setPrimaryButtonEnabled(false); - final submitError = await onSubmitted(value: textController.text); - contentWidgetSetState?.call(() => contentWidgetError = submitError); + final submitError = await onSubmitted(value: contentKey.currentState?.controller.text); + contentKey.currentState?.setError(submitError); }, primaryButtonText: primaryButtonText, - contentWidgetBuilder: (setPrimaryButtonEnabled) => StatefulBuilder( - builder: (context, setState) { - contentWidgetSetState = setState; - - return SizedBox( - width: min(MediaQuery.of(context).size.width, 700), - child: TypeAheadField( - controller: textController, - builder: (context, controller, focusNode) => TextField( - controller: controller, - focusNode: focusNode, - onChanged: (value) { - setPrimaryButtonEnabled(value.trim().isNotEmpty); - setState(() => contentWidgetError = null); - }, - autofocus: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: inputLabel, - errorText: contentWidgetError, - ), - onSubmitted: (text) async { - setPrimaryButtonEnabled(false); - final submitError = await onSubmitted(value: text); - setState(() => contentWidgetError = submitError); - }, - ), - suggestionsCallback: getSuggestions, - itemBuilder: (context, payload) => suggestionBuilder(payload), - onSelected: (payload) async { - setPrimaryButtonEnabled(false); - final submitError = await onSubmitted(payload: payload); - setState(() => contentWidgetError = submitError); - }, - hideOnEmpty: true, - hideOnLoading: true, - hideOnError: true, - ), - ); - }, + contentWidgetBuilder: (setPrimaryButtonEnabled) => _ThunderTypeaheadDialogContent( + key: contentKey, + inputLabel: inputLabel, + setPrimaryButtonEnabled: setPrimaryButtonEnabled, + onSubmitted: onSubmitted, + getSuggestions: getSuggestions, + suggestionBuilder: suggestionBuilder, ), ); } + +class _ThunderTypeaheadDialogContent extends StatefulWidget { + const _ThunderTypeaheadDialogContent({ + super.key, + required this.inputLabel, + required this.setPrimaryButtonEnabled, + required this.onSubmitted, + required this.getSuggestions, + required this.suggestionBuilder, + }); + + final String inputLabel; + final void Function(bool) setPrimaryButtonEnabled; + final Future Function({T? payload, String? value}) onSubmitted; + final FutureOr?> Function(String query) getSuggestions; + final Widget Function(T payload) suggestionBuilder; + + @override + State<_ThunderTypeaheadDialogContent> createState() => _ThunderTypeaheadDialogContentState(); +} + +class _ThunderTypeaheadDialogContentState extends State<_ThunderTypeaheadDialogContent> { + late final TextEditingController controller; + String? _errorText; + + void setError(String? error) { + setState(() => _errorText = error); + } + + @override + void initState() { + super.initState(); + controller = TextEditingController(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + Future _submit({T? payload, String? value}) async { + widget.setPrimaryButtonEnabled(false); + final submitError = await widget.onSubmitted(payload: payload, value: value); + setError(submitError); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: min(MediaQuery.sizeOf(context).width, 700), + child: TypeAheadField( + controller: controller, + builder: (context, textController, focusNode) => TextField( + controller: textController, + focusNode: focusNode, + onChanged: (value) { + widget.setPrimaryButtonEnabled(value.trim().isNotEmpty); + setError(null); + }, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: widget.inputLabel, + errorText: _errorText, + ), + onSubmitted: (text) => _submit(value: text), + ), + suggestionsCallback: widget.getSuggestions, + itemBuilder: (context, payload) => widget.suggestionBuilder(payload), + onSelected: (payload) => _submit(payload: payload), + hideOnEmpty: true, + hideOnLoading: true, + hideOnError: true, + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/fab/thunder_expandable_fab.dart b/lib/packages/ui/src/widgets/fab/thunder_expandable_fab.dart index d53385d81..8d506c18c 100644 --- a/lib/packages/ui/src/widgets/fab/thunder_expandable_fab.dart +++ b/lib/packages/ui/src/widgets/fab/thunder_expandable_fab.dart @@ -3,6 +3,10 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Compact or default-styled action button used by [ThunderExpandableFab]. +@immutable class ThunderFabActionButton extends StatelessWidget { const ThunderFabActionButton({ super.key, @@ -13,10 +17,19 @@ class ThunderFabActionButton extends StatelessWidget { this.compact = false, }); - final VoidCallback? onPressed; + /// Called when the button is pressed. + final void Function()? onPressed; + + /// Icon shown on the button. final Icon icon; + + /// Optional label shown beside the icon in compact mode. final String? label; + + /// Background color for the default (non-compact) button. final Color? backgroundColor; + + /// When true, renders a wider pill-shaped button with an inline label. final bool compact; @override @@ -25,21 +38,21 @@ class ThunderFabActionButton extends StatelessWidget { if (compact) { return SizedBox( - width: 160, + width: 160.0, child: Material( color: Colors.transparent, elevation: 3, - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(16.0)), child: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(16.0)), onTap: onPressed, child: SizedBox( - height: 40, + height: 40.0, child: Row( children: [ - const SizedBox(width: 12), - Icon(icon.icon, size: 20), - const SizedBox(width: 10), + const SizedBox(width: 12.0), + Icon(icon.icon, size: 20.0), + const SizedBox(width: 10.0), Expanded( child: Text( label ?? '', @@ -47,7 +60,7 @@ class ThunderFabActionButton extends StatelessWidget { maxLines: 1, ), ), - const SizedBox(width: 12), + const SizedBox(width: 12.0), ], ), ), @@ -60,12 +73,12 @@ class ThunderFabActionButton extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (label != null) Text(label!), - if (label != null) const SizedBox(width: 16), + if (label != null) const SizedBox(width: 16.0), SizedBox( - height: 40, - width: 40, + height: 40.0, + width: 40.0, child: Material( - borderRadius: const BorderRadius.all(Radius.circular(8)), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), clipBehavior: Clip.antiAlias, color: backgroundColor ?? theme.colorScheme.primaryContainer, elevation: 4, @@ -77,9 +90,12 @@ class ThunderFabActionButton extends StatelessWidget { } } +/// Expandable floating action button with directional child actions. +@immutable class ThunderExpandableFab extends StatefulWidget { const ThunderExpandableFab({ super.key, + this.open, this.initialOpen = false, required this.distance, required this.children, @@ -95,19 +111,47 @@ class ThunderExpandableFab extends StatefulWidget { this.onOpenChanged, }); + /// Controlled open state. When null, open state is managed internally. + final bool? open; + + /// Initial open state when [open] is null. final bool initialOpen; + + /// Distance between stacked child action buttons. final double distance; + + /// Action buttons revealed when the FAB is expanded. final List children; + + /// Icon shown on the main FAB. final Icon icon; - final VoidCallback? onSlideUp; - final VoidCallback? onSlideLeft; - final VoidCallback? onSlideDown; - final VoidCallback? onPressed; - final VoidCallback? onLongPress; + + /// Called when the user slides up on the main FAB. + final void Function()? onSlideUp; + + /// Called when the user slides left on the main FAB. + final void Function()? onSlideLeft; + + /// Called when the user slides down on the main FAB. + final void Function()? onSlideDown; + + /// Called when the main FAB is tapped. + final void Function()? onPressed; + + /// Called when the main FAB is long-pressed. + final void Function()? onLongPress; + + /// When true, centers the FAB horizontally in its parent. final bool centered; + + /// Hero tag for the main FAB. final String? heroTag; + + /// Background color for the main FAB. final Color? fabBackgroundColor; - final ValueChanged? onOpenChanged; + + /// Called when the expanded state changes. + final void Function(bool)? onOpenChanged; @override State createState() => _ThunderExpandableFabState(); @@ -121,7 +165,7 @@ class _ThunderExpandableFabState extends State with Single @override void initState() { super.initState(); - _isOpen = widget.initialOpen; + _isOpen = widget.open ?? widget.initialOpen; _controller = AnimationController( value: _isOpen ? 1.0 : 0.0, duration: const Duration(milliseconds: 250), @@ -134,6 +178,15 @@ class _ThunderExpandableFabState extends State with Single ); } + @override + void didUpdateWidget(ThunderExpandableFab oldWidget) { + super.didUpdateWidget(oldWidget); + final controlledOpen = widget.open; + if (controlledOpen != null && controlledOpen != _isOpen) { + _setOpen(controlledOpen); + } + } + @override void dispose() { _controller.dispose(); @@ -158,37 +211,80 @@ class _ThunderExpandableFabState extends State with Single alignment: widget.centered ? Alignment.bottomCenter : Alignment.bottomRight, clipBehavior: Clip.none, children: [ - _buildTapToCloseFab(), - ..._buildExpandingActionButtons(), - _buildTapToOpenFab(), + _ThunderTapToCloseFab( + centered: widget.centered, + expandAnimation: _expandAnimation, + onClose: () => _setOpen(false), + ), + for (var i = 0, distance = widget.distance; i < widget.children.length; i++, distance += widget.distance) + _ThunderExpandingActionButton( + maxDistance: distance, + progress: _expandAnimation, + centered: widget.centered, + child: widget.children[i], + ), + _ThunderTapToOpenFab( + centered: widget.centered, + isOpen: _isOpen, + icon: widget.icon, + heroTag: widget.heroTag, + fabBackgroundColor: widget.fabBackgroundColor, + onPressed: widget.onPressed, + onLongPress: widget.onLongPress, + onSlideUp: widget.onSlideUp, + onSlideLeft: widget.onSlideLeft, + onSlideDown: widget.onSlideDown, + onOpen: () => _setOpen(true), + ), ], ), ); } +} + +/// Close overlay shown when [ThunderExpandableFab] is expanded. +class _ThunderTapToCloseFab extends StatelessWidget { + const _ThunderTapToCloseFab({ + required this.centered, + required this.expandAnimation, + required this.onClose, + }); + + /// Whether the FAB is centered horizontally. + final bool centered; + + /// Animation driving the expand/collapse transition. + final Animation expandAnimation; + + /// Called when the close affordance is tapped. + final void Function() onClose; + + @override + Widget build(BuildContext context) { + final tileBorderRadius = ThunderTheme.of(context).tileBorderRadius; - Widget _buildTapToCloseFab() { return SizedBox( - width: widget.centered ? 45 : 56, - height: widget.centered ? 45 : 56, + width: centered ? 45.0 : 56.0, + height: centered ? 45.0 : 56.0, child: AnimatedBuilder( - animation: _expandAnimation, + animation: expandAnimation, builder: (context, child) => child!, child: FadeTransition( - opacity: _expandAnimation, + opacity: expandAnimation, child: Center( child: Material( - shape: widget.centered ? null : const CircleBorder(), - clipBehavior: widget.centered ? Clip.none : Clip.antiAlias, - color: widget.centered ? Colors.transparent : null, - elevation: widget.centered ? 0 : 4, + shape: centered ? null : const CircleBorder(), + clipBehavior: centered ? Clip.none : Clip.antiAlias, + color: centered ? Colors.transparent : null, + elevation: centered ? 0.0 : 4.0, child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () => _setOpen(false), + borderRadius: tileBorderRadius, + onTap: onClose, child: Padding( - padding: EdgeInsets.all(widget.centered ? 12 : 8), + padding: EdgeInsets.all(centered ? 12.0 : 8.0), child: Icon( Icons.close, - size: widget.centered ? 20 : 25, + size: centered ? 20.0 : 25.0, color: Theme.of(context).textTheme.bodyMedium?.color, ), ), @@ -199,77 +295,112 @@ class _ThunderExpandableFabState extends State with Single ), ); } +} - List _buildExpandingActionButtons() { - final children = []; - final count = widget.children.length; - - for (var i = 0, distance = widget.distance; i < count; i++, distance += widget.distance) { - children.add( - _ThunderExpandingActionButton( - maxDistance: distance, - progress: _expandAnimation, - centered: widget.centered, - child: widget.children[i], - ), - ); - } +/// Main FAB that opens [ThunderExpandableFab] child actions. +class _ThunderTapToOpenFab extends StatelessWidget { + const _ThunderTapToOpenFab({ + required this.centered, + required this.isOpen, + required this.icon, + required this.onOpen, + this.heroTag, + this.fabBackgroundColor, + this.onPressed, + this.onLongPress, + this.onSlideUp, + this.onSlideLeft, + this.onSlideDown, + }); - return children; - } + /// Whether the FAB is centered horizontally. + final bool centered; + + /// Whether the expandable FAB is currently open. + final bool isOpen; + + /// Icon shown on the main FAB. + final Icon icon; + + /// Hero tag for the main FAB. + final String? heroTag; + + /// Background color for the main FAB. + final Color? fabBackgroundColor; + + /// Called when the main FAB is tapped. + final void Function()? onPressed; + + /// Called when the main FAB is long-pressed. + final void Function()? onLongPress; + + /// Called when the user slides up on the main FAB. + final void Function()? onSlideUp; + + /// Called when the user slides left on the main FAB. + final void Function()? onSlideLeft; + + /// Called when the user slides down on the main FAB. + final void Function()? onSlideDown; + + /// Called to open the expandable FAB. + final void Function() onOpen; + + @override + Widget build(BuildContext context) { + final tileBorderRadius = ThunderTheme.of(context).tileBorderRadius; - Widget _buildTapToOpenFab() { return IgnorePointer( - ignoring: _isOpen, + ignoring: isOpen, child: AnimatedContainer( transformAlignment: Alignment.center, - transform: Matrix4.diagonal3Values(_isOpen ? 0.7 : 1, _isOpen ? 0.7 : 1, 1), + transform: Matrix4.diagonal3Values(isOpen ? 0.7 : 1, isOpen ? 0.7 : 1, 1), duration: const Duration(milliseconds: 250), curve: const Interval(0, 0.5, curve: Curves.easeOut), child: AnimatedOpacity( - opacity: _isOpen ? 0 : 1, + opacity: isOpen ? 0.0 : 1.0, curve: const Interval(0.25, 1, curve: Curves.easeInOut), duration: const Duration(milliseconds: 250), child: GestureDetector( onVerticalDragUpdate: (details) { if (details.delta.dy < -5) { - _setOpen(true); - widget.onSlideUp?.call(); + onOpen(); + onSlideUp?.call(); } if (details.delta.dy > 5) { - widget.onSlideDown?.call(); + onSlideDown?.call(); } }, onHorizontalDragUpdate: (details) { - if (details.delta.dx < -5) widget.onSlideLeft?.call(); + if (details.delta.dx < -5) onSlideLeft?.call(); }, onLongPress: () { HapticFeedback.heavyImpact(); - widget.onLongPress?.call(); + onLongPress?.call(); }, onTapDown: (_) => HapticFeedback.mediumImpact(), - child: widget.centered + child: centered ? SizedBox( - width: 45, - height: 45, + width: 45.0, + height: 45.0, child: Material( clipBehavior: Clip.antiAlias, color: Colors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(50), + borderRadius: tileBorderRadius, onTap: () { HapticFeedback.mediumImpact(); - widget.onPressed?.call(); + onPressed?.call(); }, - child: Icon(widget.icon.icon, size: 20, semanticLabel: widget.icon.semanticLabel), + child: Icon(icon.icon, size: 20.0, semanticLabel: icon.semanticLabel), ), ), ) : FloatingActionButton( - heroTag: widget.heroTag, - backgroundColor: widget.fabBackgroundColor, - onPressed: widget.onPressed, - child: widget.icon, + heroTag: heroTag, + backgroundColor: fabBackgroundColor, + onPressed: onPressed, + child: icon, ), ), ), @@ -278,8 +409,9 @@ class _ThunderExpandableFabState extends State with Single } } +/// Positions and animates one child action in [ThunderExpandableFab]. @immutable -class _ThunderExpandingActionButton extends StatefulWidget { +class _ThunderExpandingActionButton extends StatelessWidget { const _ThunderExpandingActionButton({ required this.maxDistance, required this.progress, @@ -287,39 +419,39 @@ class _ThunderExpandingActionButton extends StatefulWidget { this.centered = false, }); + /// Maximum vertical offset from the main FAB. final double maxDistance; + + /// Expand animation progress. final Animation progress; - final Widget child; - final bool centered; - @override - State<_ThunderExpandingActionButton> createState() => _ThunderExpandingActionButtonState(); -} + /// The action button to position. + final Widget child; -class _ThunderExpandingActionButtonState extends State<_ThunderExpandingActionButton> { - bool _visible = false; + /// Whether the FAB stack is centered horizontally. + final bool centered; @override Widget build(BuildContext context) { return AnimatedBuilder( - animation: widget.progress, + animation: progress, builder: (context, child) { final offset = Offset.fromDirection( 90 * (math.pi / 180.0), - widget.progress.value * widget.maxDistance, + progress.value * maxDistance, ); - _visible = !widget.progress.isDismissed; + final visible = !progress.isDismissed; return Visibility( - visible: _visible, + visible: visible, child: Positioned( - right: widget.centered ? null : 8 + offset.dx, - bottom: (widget.centered ? 15 : 10) + offset.dy, + right: centered ? null : 8 + offset.dx, + bottom: (centered ? 15.0 : 10.0) + offset.dy, child: child!, ), ); }, - child: FadeTransition(opacity: widget.progress, child: widget.child), + child: FadeTransition(opacity: progress, child: child), ); } } diff --git a/lib/packages/ui/src/widgets/feedback/snackbar.dart b/lib/packages/ui/src/widgets/feedback/snackbar.dart deleted file mode 100644 index 80b8ad2d6..000000000 --- a/lib/packages/ui/src/widgets/feedback/snackbar.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:overlay_support/overlay_support.dart'; - -const Duration _snackBarTransitionDuration = Duration(milliseconds: 500); - -void showSnackbar( - String text, { - Duration? duration, - Color? backgroundColor, - Color? leadingIconColor, - IconData? leadingIcon, - Color? trailingIconColor, - IconData? trailingIcon, - bool closable = true, - void Function()? trailingAction, -}) { - final int wordCount = RegExp(r'[\w-]+').allMatches(text).length; - - const key = TransientKey('transient'); - - WidgetsBinding.instance.addPostFrameCallback((_) { - showOverlay( - (context, progress) { - return SnackbarNotification( - builder: (context) => ThunderSnackbar( - content: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (leadingIcon != null) ...[Icon(leadingIcon, color: leadingIconColor), const SizedBox(width: 8.0)], - Expanded(child: Text(text)), - if (trailingIcon != null) - Padding( - padding: const EdgeInsets.only(left: 12.0), - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: trailingAction != null - ? () { - OverlaySupportEntry.of(context)?.dismiss(); - trailingAction(); - } - : null, - child: Icon(trailingIcon, color: trailingIconColor ?? Theme.of(context).colorScheme.inversePrimary), - ), - ), - if (closable) - Padding( - padding: const EdgeInsets.only(left: 12.0), - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () => OverlaySupportEntry.of(context)?.dismiss(), - child: Icon(Icons.close_rounded, color: Theme.of(context).colorScheme.surface), - ), - ), - ], - ), - closable: closable, - ), - progress: progress, - ); - }, - animationDuration: _snackBarTransitionDuration, - duration: duration ?? Duration(milliseconds: max(kNotificationDuration.inMilliseconds, max(4000, 1000 * wordCount))), - key: key, - ); - }); -} - -class SnackbarNotification extends StatefulWidget { - final WidgetBuilder builder; - - final double progress; - - const SnackbarNotification({super.key, required this.builder, required this.progress}); - - @override - State createState() => _SnackbarNotificationState(); -} - -class _SnackbarNotificationState extends State with TickerProviderStateMixin { - late AnimationController _controller; - - static const Curve _snackBarM3HeightCurve = Curves.easeInOutQuart; - static const Curve _snackBarM3FadeInCurve = Interval(0.4, 0.6, curve: Curves.easeInCirc); - static const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - vsync: this, - duration: _snackBarTransitionDuration, - ); - } - - @override - void didUpdateWidget(SnackbarNotification oldWidget) { - super.didUpdateWidget(oldWidget); - - if ((widget.progress - oldWidget.progress) > 0) { - if (!_controller.isAnimating) _controller.forward(); - } else if ((widget.progress - oldWidget.progress) < 0) { - if (!_controller.isAnimating) _controller.reverse(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final CurvedAnimation fadeInM3Animation = CurvedAnimation(parent: _controller, curve: _snackBarM3FadeInCurve, reverseCurve: _snackBarFadeOutCurve); - - final CurvedAnimation heightM3Animation = CurvedAnimation( - parent: _controller, - curve: _snackBarM3HeightCurve, - reverseCurve: const Threshold(0.0), - ); - - return FadeTransition( - opacity: fadeInM3Animation, - child: AnimatedBuilder( - animation: heightM3Animation, - builder: (BuildContext context, Widget? child) { - return Align( - alignment: AlignmentDirectional.bottomStart, - heightFactor: heightM3Animation.value, - child: child, - ); - }, - child: widget.builder(context), - ), - ); - } -} - -class ThunderSnackbar extends StatefulWidget { - final Widget content; - - final bool closable; - - const ThunderSnackbar({super.key, required this.content, this.closable = true}); - - @override - State createState() => _ThunderSnackbarState(); -} - -class _ThunderSnackbarState extends State with WidgetsBindingObserver { - final double horizontalPadding = 16.0; - final double singleLineVerticalPadding = 14.0; - - double snackbarBottomPadding = 0; - Widget child = Container(); - - double calculateBottomPadding() { - final double minimumPadding = MediaQuery.viewPaddingOf(context).bottom + kBottomNavigationBarHeight + singleLineVerticalPadding; - final double bottomViewInsets = MediaQuery.viewInsetsOf(context).bottom; - - return max(minimumPadding, bottomViewInsets); - } - - void rebuildSnackbar() { - final ThemeData theme = Theme.of(context); - final SnackBarThemeData snackBarTheme = theme.snackBarTheme; - - final double elevation = snackBarTheme.elevation ?? 6.0; - final Color backgroundColor = theme.colorScheme.inverseSurface; - final ShapeBorder shape = snackBarTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)); - - child = SafeArea( - child: Container( - padding: EdgeInsets.only(bottom: calculateBottomPadding()), - child: ClipRect( - child: Align( - alignment: AlignmentDirectional.bottomStart, - child: Semantics( - container: true, - liveRegion: true, - child: Padding( - padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 0.0), - child: Material( - shape: shape, - elevation: elevation, - color: backgroundColor, - clipBehavior: Clip.none, - child: Theme( - data: theme, - child: Padding( - padding: EdgeInsetsDirectional.only(start: horizontalPadding, end: widget.closable ? 12.0 : 8.0), - child: Wrap( - children: [ - Row( - children: [ - Expanded( - child: Container( - padding: EdgeInsets.symmetric(vertical: singleLineVerticalPadding), - child: DefaultTextStyle( - style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.onInverseSurface), - child: widget.content, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => rebuildSnackbar()); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeMetrics() { - final double newSnackbarBottomPadding = calculateBottomPadding(); - - if (snackbarBottomPadding != newSnackbarBottomPadding) { - snackbarBottomPadding = newSnackbarBottomPadding; - rebuildSnackbar(); - setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - return Dismissible( - key: UniqueKey(), - direction: DismissDirection.down, - behavior: HitTestBehavior.deferToChild, - onDismissed: (direction) { - setState(() => child = Container()); - }, - child: child, - ); - } -} diff --git a/lib/packages/ui/src/widgets/feedback/thunder_snackbar.dart b/lib/packages/ui/src/widgets/feedback/thunder_snackbar.dart new file mode 100644 index 000000000..98f59da49 --- /dev/null +++ b/lib/packages/ui/src/widgets/feedback/thunder_snackbar.dart @@ -0,0 +1,316 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:overlay_support/overlay_support.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +const Duration _snackBarTransitionDuration = Duration(milliseconds: 500); +const _snackbarDismissibleKey = ValueKey('thunder_snackbar_dismissible'); + +/// Shows a Thunder-styled overlay snackbar with optional icons and actions. +/// +/// Duration defaults based on word count when [duration] is not provided. +/// The snackbar is positioned above the bottom navigation bar. +void showThunderSnackbar( + String text, { + Duration? duration, + Color? backgroundColor, + Color? leadingIconColor, + IconData? leadingIcon, + Color? trailingIconColor, + IconData? trailingIcon, + bool closable = true, + void Function()? trailingAction, +}) { + final int wordCount = RegExp(r'[\w-]+').allMatches(text).length; + + const key = TransientKey('transient'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + showOverlay( + (context, progress) { + return _ThunderSnackbarNotification( + builder: (context) => ThunderSnackbar( + backgroundColor: backgroundColor, + content: _ThunderSnackbarContent( + text: text, + leadingIcon: leadingIcon, + leadingIconColor: leadingIconColor, + trailingIcon: trailingIcon, + trailingIconColor: trailingIconColor, + trailingAction: trailingAction, + closable: closable, + ), + closable: closable, + ), + progress: progress, + ); + }, + animationDuration: _snackBarTransitionDuration, + duration: duration ?? Duration(milliseconds: max(kNotificationDuration.inMilliseconds, max(4000, 1000 * wordCount))), + key: key, + ); + }); +} + +/// Row content for [ThunderSnackbar] with icons and actions. +class _ThunderSnackbarContent extends StatelessWidget { + const _ThunderSnackbarContent({ + required this.text, + required this.closable, + this.leadingIcon, + this.leadingIconColor, + this.trailingIcon, + this.trailingIconColor, + this.trailingAction, + }); + + /// The snackbar message text. + final String text; + + /// Whether a close affordance is shown. + final bool closable; + + /// Optional icon shown before the text. + final IconData? leadingIcon; + + /// Color for the leading icon. + final Color? leadingIconColor; + + /// Optional icon shown after the text. + final IconData? trailingIcon; + + /// Color for the trailing icon. + final Color? trailingIconColor; + + /// Called when the trailing icon is tapped. + final void Function()? trailingAction; + + @override + Widget build(BuildContext context) { + final tileBorderRadius = ThunderTheme.of(context).tileBorderRadius; + final theme = Theme.of(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (leadingIcon != null) ...[ + Icon(leadingIcon, color: leadingIconColor), + const SizedBox(width: 8.0), + ], + Expanded(child: Text(text)), + if (trailingIcon != null) + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: InkWell( + borderRadius: tileBorderRadius, + onTap: trailingAction != null + ? () { + OverlaySupportEntry.of(context)?.dismiss(); + trailingAction!(); + } + : null, + child: Icon(trailingIcon, color: trailingIconColor ?? theme.colorScheme.inversePrimary), + ), + ), + if (closable) + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: InkWell( + borderRadius: tileBorderRadius, + onTap: () => OverlaySupportEntry.of(context)?.dismiss(), + child: Icon(Icons.close_rounded, color: theme.colorScheme.surface), + ), + ), + ], + ); + } +} + +class _ThunderSnackbarNotification extends StatefulWidget { + const _ThunderSnackbarNotification({required this.builder, required this.progress}); + + final WidgetBuilder builder; + final double progress; + + @override + State<_ThunderSnackbarNotification> createState() => _ThunderSnackbarNotificationState(); +} + +class _ThunderSnackbarNotificationState extends State<_ThunderSnackbarNotification> with TickerProviderStateMixin { + late final AnimationController _controller; + late final CurvedAnimation _fadeInM3Animation; + late final CurvedAnimation _heightM3Animation; + + static const Curve _snackBarM3HeightCurve = Curves.easeInOutQuart; + static const Curve _snackBarM3FadeInCurve = Interval(0.4, 0.6, curve: Curves.easeInCirc); + static const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: _snackBarTransitionDuration, + ); + _fadeInM3Animation = CurvedAnimation(parent: _controller, curve: _snackBarM3FadeInCurve, reverseCurve: _snackBarFadeOutCurve); + _heightM3Animation = CurvedAnimation( + parent: _controller, + curve: _snackBarM3HeightCurve, + reverseCurve: const Threshold(0.0), + ); + } + + @override + void didUpdateWidget(_ThunderSnackbarNotification oldWidget) { + super.didUpdateWidget(oldWidget); + + if ((widget.progress - oldWidget.progress) > 0) { + if (!_controller.isAnimating) _controller.forward(); + } else if ((widget.progress - oldWidget.progress) < 0) { + if (!_controller.isAnimating) _controller.reverse(); + } + } + + @override + void dispose() { + _fadeInM3Animation.dispose(); + _heightM3Animation.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeInM3Animation, + child: AnimatedBuilder( + animation: _heightM3Animation, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.bottomStart, + heightFactor: _heightM3Animation.value, + child: child, + ); + }, + child: widget.builder(context), + ), + ); + } +} + +/// Material snackbar body positioned above the bottom navigation bar. +class ThunderSnackbar extends StatefulWidget { + const ThunderSnackbar({ + super.key, + required this.content, + this.closable = true, + this.backgroundColor, + }); + + /// Snackbar body content, typically a [Row] of text and action icons. + final Widget content; + + /// Whether the snackbar reserves trailing space for a close affordance. + final bool closable; + + /// Background color for the snackbar surface. Defaults to [ColorScheme.inverseSurface]. + final Color? backgroundColor; + + @override + State createState() => _ThunderSnackbarState(); +} + +class _ThunderSnackbarState extends State with WidgetsBindingObserver { + static const double _horizontalPadding = 16.0; + static const double _singleLineVerticalPadding = 14.0; + + bool _dismissed = false; + + double _calculateBottomPadding(BuildContext context) { + final double minimumPadding = MediaQuery.viewPaddingOf(context).bottom + kBottomNavigationBarHeight + _singleLineVerticalPadding; + final double bottomViewInsets = MediaQuery.viewInsetsOf(context).bottom; + + return max(minimumPadding, bottomViewInsets); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (_dismissed) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final snackBarTheme = theme.snackBarTheme; + final elevation = snackBarTheme.elevation ?? 6.0; + final backgroundColor = widget.backgroundColor ?? theme.colorScheme.inverseSurface; + final shape = snackBarTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)); + + return Dismissible( + key: _snackbarDismissibleKey, + direction: DismissDirection.down, + behavior: HitTestBehavior.deferToChild, + onDismissed: (_) => setState(() => _dismissed = true), + child: SafeArea( + child: Container( + padding: EdgeInsets.only(bottom: _calculateBottomPadding(context)), + child: ClipRect( + child: Align( + alignment: AlignmentDirectional.bottomStart, + child: Semantics( + container: true, + liveRegion: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 0.0), + child: Material( + shape: shape, + elevation: elevation, + color: backgroundColor, + clipBehavior: Clip.none, + child: Theme( + data: theme, + child: Padding( + padding: EdgeInsetsDirectional.only(start: _horizontalPadding, end: widget.closable ? 12.0 : 8.0), + child: Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding), + child: DefaultTextStyle( + style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.onInverseSurface), + child: widget.content, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/identity/scalable_text.dart b/lib/packages/ui/src/widgets/identity/thunder_scalable_text.dart similarity index 61% rename from lib/packages/ui/src/widgets/identity/scalable_text.dart rename to lib/packages/ui/src/widgets/identity/thunder_scalable_text.dart index 8c4bd2137..45c9fe6a2 100644 --- a/lib/packages/ui/src/widgets/identity/scalable_text.dart +++ b/lib/packages/ui/src/widgets/identity/thunder_scalable_text.dart @@ -1,31 +1,43 @@ import 'package:flutter/material.dart'; -class ScalableText extends StatelessWidget { +/// Text that applies a manual scale factor while ignoring system text scaling. +/// +/// System [MediaQuery.textScaler] is read once to compute the final font size, +/// then [TextScaler.noScaling] is used so the result is not scaled again. +@immutable +class ThunderScalableText extends StatelessWidget { + const ThunderScalableText( + this.text, { + super.key, + this.style, + this.textAlign, + this.textScaleFactor = 1.0, + this.semanticsLabel, + this.overflow, + this.maxLines, + }); + + /// The text to display. final String text; + /// Base style before scaling. Defaults to [ThemeData.textTheme.bodyMedium]. final TextStyle? style; + /// How the text should be aligned horizontally. final TextAlign? textAlign; + /// Multiplier applied on top of the system text scaler. final double textScaleFactor; + /// Semantic label for accessibility. final String? semanticsLabel; + /// How visual overflow should be handled. final TextOverflow? overflow; + /// Maximum number of lines for the text to span. final int? maxLines; - const ScalableText( - this.text, { - super.key, - this.style, - this.textAlign, - this.textScaleFactor = 1.0, - this.semanticsLabel, - this.overflow, - this.maxLines, - }); - @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/lib/packages/ui/src/widgets/layout/thunder_bottom_sheet.dart b/lib/packages/ui/src/widgets/layout/thunder_bottom_sheet.dart index 721add5d7..665a7b70e 100644 --- a/lib/packages/ui/src/widgets/layout/thunder_bottom_sheet.dart +++ b/lib/packages/ui/src/widgets/layout/thunder_bottom_sheet.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +/// Title row for Thunder bottom sheets with optional leading and trailing slots. +@immutable class ThunderBottomSheetHeader extends StatelessWidget { const ThunderBottomSheetHeader({ super.key, @@ -9,9 +11,16 @@ class ThunderBottomSheetHeader extends StatelessWidget { this.trailing, }); + /// Primary heading text. final String title; + + /// Optional secondary text shown below [title]. final String? subtitle; + + /// Widget shown before the title column, such as a back button. final Widget? leading; + + /// Widget shown after the title column, such as a close button. final Widget? trailing; @override @@ -19,13 +28,13 @@ class ThunderBottomSheetHeader extends StatelessWidget { final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ if (leading != null) ...[ leading!, - const SizedBox(width: 12), + const SizedBox(width: 12.0), ], Expanded( child: Column( @@ -33,7 +42,7 @@ class ThunderBottomSheetHeader extends StatelessWidget { children: [ Text(title, style: theme.textTheme.titleLarge), if (subtitle != null) ...[ - const SizedBox(height: 2), + const SizedBox(height: 2.0), Text(subtitle!, style: theme.textTheme.bodySmall), ], ], @@ -46,6 +55,7 @@ class ThunderBottomSheetHeader extends StatelessWidget { } } +/// Multi-page bottom sheet navigator with a shared header and back/close controls. class ThunderBottomSheetNavigator extends StatefulWidget { const ThunderBottomSheetNavigator({ super.key, @@ -56,11 +66,20 @@ class ThunderBottomSheetNavigator extends StatefulWidget { this.onClose, }); + /// The first page pushed onto the internal navigation stack. final T initialPage; - final Widget Function(BuildContext context, T page, void Function(T nextPage) goTo, VoidCallback goBack) pageBuilder; + + /// Builds the body for [page] and exposes [goTo] and [goBack] navigation callbacks. + final Widget Function(BuildContext context, T page, void Function(T nextPage) goTo, void Function() goBack) pageBuilder; + + /// Returns the header title for [page]. final String Function(BuildContext context, T page) titleBuilder; + + /// When provided, controls whether the back action is allowed for [page]. final bool Function(T page)? canPop; - final VoidCallback? onClose; + + /// Called when the sheet is closed via the close button or a pop from the root page. + final void Function()? onClose; @override State> createState() => _ThunderBottomSheetNavigatorState(); diff --git a/lib/packages/ui/src/widgets/layout/thunder_composer_bar.dart b/lib/packages/ui/src/widgets/layout/thunder_composer_bar.dart new file mode 100644 index 000000000..f51859767 --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_composer_bar.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Compact composer row with leading action, text field, and trailing action slots. +@immutable +class ThunderComposerBar extends StatelessWidget { + const ThunderComposerBar({ + super.key, + this.leading, + required this.textField, + required this.trailing, + this.padding, + }); + + /// Optional widget before the text field. + final Widget? leading; + + /// Main text input widget. + final Widget textField; + + /// Trailing action widget outside the input container. + final Widget trailing; + + /// Outer padding. Defaults to composer bar insets. + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final resolvedPadding = padding ?? EdgeInsets.fromLTRB(16.0, 4.0, 12.0, 8.0 + MediaQuery.paddingOf(context).bottom); + + return Container( + padding: resolvedPadding, + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(32.0), + ), + padding: const EdgeInsets.only(left: 0.0, right: 12.0, top: 4.0, bottom: 4.0), + child: Row( + children: [ + if (leading != null) leading!, + Expanded(child: textField), + ], + ), + ), + ), + const SizedBox(width: 4.0), + trailing, + ], + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/conditional_parent_widget.dart b/lib/packages/ui/src/widgets/layout/thunder_conditional_parent.dart similarity index 83% rename from lib/packages/ui/src/widgets/layout/conditional_parent_widget.dart rename to lib/packages/ui/src/widgets/layout/thunder_conditional_parent.dart index 658394803..a7c990786 100644 --- a/lib/packages/ui/src/widgets/layout/conditional_parent_widget.dart +++ b/lib/packages/ui/src/widgets/layout/thunder_conditional_parent.dart @@ -1,80 +1,82 @@ -import 'package:flutter/widgets.dart'; - -typedef ParentBuilder = Widget Function(Widget child); - -/// {@template conditionalParent} -/// Conditionally wrap a subtree with a parent widget without breaking the code tree. -/// -/// - [condition] controls how/whether the [child] is wrapped. -/// - [child] the subtree that should always be build. -/// - [parentBuilder] build this parent with the subtree [child] if [condition] is `true`. -/// - [parentBuilderElse] build this parent with the subtree [child] if [condition] is `false`. -/// return [child] if [condition] is `false` and [parentBuilderElse] is null. -/// -/// ___________ -/// Tree will look like: -/// ```dart -/// return SomeWidget( -/// child: SomeOtherWidget( -/// child: ConditionalParentWidget( -/// condition: shouldIncludeParent, -/// parentBuilder: (Widget child) => SomeParentWidget(child: child), -/// child: Widget1( -/// child: Widget2( -/// child: Widget3(), -/// ), -/// ), -/// ), -/// ), -/// ); -/// ``` -/// -/// ___________ -/// Instead of: -/// ```dart -/// Widget child = Widget1( -/// child: Widget2( -/// child: Widget3(), -/// ), -/// ); -/// -/// return SomeWidget( -/// child: SomeOtherWidget( -/// child: shouldIncludeParent -/// ? SomeParentWidget(child: child) -/// : child -/// ), -/// ); -/// ``` -/// {@endtemplate} -class ConditionalParentWidget extends StatelessWidget { - /// {@macro conditionalParent} - const ConditionalParentWidget({ - super.key, - required this.condition, - required this.parentBuilder, - this.parentBuilderElse, - required this.child, - }); - - /// The [condition] which controls how/whether the [child] is wrapped. - final bool condition; - - /// The [child] which should be conditionally wrapped. - final Widget child; - - /// Builder to wrap [child] when [condition] is `true`. - final ParentBuilder? parentBuilder; - - /// Optional builder to wrap [child] when [condition] is `false`. - /// - /// [child] is returned directly when this is `null`. - final ParentBuilder? parentBuilderElse; - - @override - Widget build(BuildContext context) { - return condition // - ? parentBuilder?.call(child) ?? child - : parentBuilderElse?.call(child) ?? child; - } -} +import 'package:flutter/widgets.dart'; + +/// Builds a parent widget around a Thunder conditional child. +typedef ThunderParentBuilder = Widget Function(Widget child); + +/// {@template conditionalParent} +/// Conditionally wrap a subtree with a parent widget without breaking the code tree. +/// +/// - [condition] controls how/whether the [child] is wrapped. +/// - [child] the subtree that should always be build. +/// - [parentBuilder] build this parent with the subtree [child] if [condition] is `true`. +/// - [parentBuilderElse] build this parent with the subtree [child] if [condition] is `false`. +/// return [child] if [condition] is `false` and [parentBuilderElse] is null. +/// +/// ___________ +/// Tree will look like: +/// ```dart +/// return SomeWidget( +/// child: SomeOtherWidget( +/// child: ThunderConditionalParent( +/// condition: shouldIncludeParent, +/// parentBuilder: (Widget child) => SomeParentWidget(child: child), +/// child: Widget1( +/// child: Widget2( +/// child: Widget3(), +/// ), +/// ), +/// ), +/// ), +/// ); +/// ``` +/// +/// ___________ +/// Instead of: +/// ```dart +/// Widget child = Widget1( +/// child: Widget2( +/// child: Widget3(), +/// ), +/// ); +/// +/// return SomeWidget( +/// child: SomeOtherWidget( +/// child: shouldIncludeParent +/// ? SomeParentWidget(child: child) +/// : child +/// ), +/// ); +/// ``` +/// {@endtemplate} +@immutable +class ThunderConditionalParent extends StatelessWidget { + /// {@macro conditionalParent} + const ThunderConditionalParent({ + super.key, + required this.condition, + required this.parentBuilder, + this.parentBuilderElse, + required this.child, + }); + + /// The [condition] which controls how/whether the [child] is wrapped. + final bool condition; + + /// The [child] which should be conditionally wrapped. + final Widget child; + + /// Builder to wrap [child] when [condition] is `true`. + final ThunderParentBuilder parentBuilder; + + /// Optional builder to wrap [child] when [condition] is `false`. + /// + /// [child] is returned directly when this is `null`. + final ThunderParentBuilder? parentBuilderElse; + + @override + Widget build(BuildContext context) { + return condition // + ? parentBuilder(child) + : parentBuilderElse?.call(child) ?? child; + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_divider.dart b/lib/packages/ui/src/widgets/layout/thunder_divider.dart index 660634738..e42bb4ccd 100644 --- a/lib/packages/ui/src/widgets/layout/thunder_divider.dart +++ b/lib/packages/ui/src/widgets/layout/thunder_divider.dart @@ -1,26 +1,50 @@ import 'package:flutter/material.dart'; -import 'package:thunder/packages/ui/src/widgets/layout/conditional_parent_widget.dart'; +import 'package:thunder/packages/ui/src/widgets/layout/thunder_conditional_parent.dart'; +/// A themed divider that can render inside a sliver list or a box layout. +@immutable class ThunderDivider extends StatelessWidget { - /// Whether to wrap the returned widget in a [SliverToBoxAdapter] + const ThunderDivider({ + super.key, + required this.sliver, + this.padding = true, + this.thickness = 2.0, + this.color, + this.height, + }); + + /// When true, wraps the divider in a [SliverToBoxAdapter]. final bool sliver; - /// Whether to apply padding around the divider + /// When true, applies horizontal indent and extra vertical spacing. final bool padding; - const ThunderDivider({super.key, required this.sliver, this.padding = true}); + /// Thickness of the divider line. + final double thickness; + + /// Divider color. Defaults to [ThemeData.dividerColor] at 60% opacity. + final Color? color; + + /// Total height of the divider widget. Defaults to 32 when [padding] is true. + final double? height; @override - Widget build(BuildContext context) => ConditionalParentWidget( - condition: sliver, - parentBuilder: (Widget child) => SliverToBoxAdapter(child: child), - child: Divider( - indent: padding ? 32.0 : 0, - height: padding ? 32.0 : 16, - endIndent: padding ? 32.0 : 0, - thickness: 2.0, - color: Theme.of(context).dividerColor.withValues(alpha: 0.6), - ), - ); + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dividerColor = color ?? theme.dividerColor.withValues(alpha: 0.6); + final dividerHeight = height ?? (padding ? 32.0 : 16.0); + + return ThunderConditionalParent( + condition: sliver, + parentBuilder: (Widget child) => SliverToBoxAdapter(child: child), + child: Divider( + indent: padding ? 32.0 : 0, + height: dividerHeight, + endIndent: padding ? 32.0 : 0, + thickness: thickness, + color: dividerColor, + ), + ); + } } diff --git a/lib/packages/ui/src/widgets/layout/thunder_marquee.dart b/lib/packages/ui/src/widgets/layout/thunder_marquee.dart new file mode 100644 index 000000000..1ad76a93e --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_marquee.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +/// Scroll animation mode for [ThunderMarquee]. +enum ThunderMarqueeDirection { + /// Scrolls in one direction only. + oneDirection, + + /// Scrolls forward, pauses, then scrolls back. + twoDirection, +} + +/// Animated scrolling text or child content. +class ThunderMarquee extends StatefulWidget { + const ThunderMarquee({ + super.key, + required this.child, + this.direction = Axis.horizontal, + this.textDirection = TextDirection.ltr, + this.animationDuration = const Duration(milliseconds: 5000), + this.backDuration = const Duration(milliseconds: 5000), + this.pauseDuration = const Duration(milliseconds: 2000), + this.marqueeDirection = ThunderMarqueeDirection.twoDirection, + this.forwardAnimation = Curves.easeIn, + this.backwardAnimation = Curves.easeOut, + this.autoRepeat = true, + }); + + /// Content to scroll. + final Widget child; + + /// Scroll axis. + final Axis direction; + + /// Text direction for the scroll view. + final TextDirection textDirection; + + /// Duration of the forward scroll animation. + final Duration animationDuration; + + /// Duration of the backward scroll animation in two-direction mode. + final Duration backDuration; + + /// Pause between scroll segments. + final Duration pauseDuration; + + /// Whether to scroll one way or back and forth. + final ThunderMarqueeDirection marqueeDirection; + + /// Curve for the forward scroll animation. + final Cubic forwardAnimation; + + /// Curve for the backward scroll animation. + final Cubic backwardAnimation; + + /// Whether to repeat the scroll animation. + final bool autoRepeat; + + @override + State createState() => _ThunderMarqueeState(); +} + +class _ThunderMarqueeState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scroll(true); + } + + Future _scroll(bool repeated) async { + do { + if (_scrollController.hasClients) { + await Future.delayed(widget.pauseDuration); + if (_scrollController.hasClients) { + await _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: widget.animationDuration, + curve: widget.forwardAnimation, + ); + } + await Future.delayed(widget.pauseDuration); + if (_scrollController.hasClients) { + switch (widget.marqueeDirection) { + case ThunderMarqueeDirection.oneDirection: + _scrollController.jumpTo(0); + case ThunderMarqueeDirection.twoDirection: + await _scrollController.animateTo(0, duration: widget.backDuration, curve: widget.backwardAnimation); + } + } + repeated = widget.autoRepeat; + } else { + await Future.delayed(widget.pauseDuration); + } + } while (repeated && mounted); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: widget.textDirection, + child: SingleChildScrollView( + scrollDirection: widget.direction, + controller: _scrollController, + child: widget.child, + ), + ); + } +} diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_metadata.dart b/lib/packages/ui/src/widgets/layout/thunder_metadata_row.dart similarity index 51% rename from lib/src/features/account/presentation/widgets/profile_modal/profile_metadata.dart rename to lib/packages/ui/src/widgets/layout/thunder_metadata_row.dart index c6c036eff..4ef426af9 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_metadata.dart +++ b/lib/packages/ui/src/widgets/layout/thunder_metadata_row.dart @@ -1,39 +1,45 @@ import 'package:flutter/material.dart'; -/// Displays an instance host with optional version metadata. -class ProfileMetadata extends StatelessWidget { - const ProfileMetadata({ +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Primary label with optional animated secondary metadata segment. +@immutable +class ThunderMetadataRow extends StatelessWidget { + const ThunderMetadataRow({ super.key, - required this.instance, - required this.version, + required this.primary, + this.secondary, }); - /// Instance host displayed at the start of the metadata row. - final String instance; + /// Primary metadata label. + final String primary; - /// Optional instance software version. - final String? version; + /// Optional secondary segment shown after a bullet separator. + final String? secondary; @override Widget build(BuildContext context) { return Wrap( children: [ - Text(instance), - _AnimatedMetadataSegment(text: version == null ? null : 'v$version'), + Text(primary), + _ThunderAnimatedMetadataSegment(text: secondary), ], ); } } -class _AnimatedMetadataSegment extends StatelessWidget { - const _AnimatedMetadataSegment({required this.text}); +/// Animated secondary metadata segment for [ThunderMetadataRow]. +class _ThunderAnimatedMetadataSegment extends StatelessWidget { + const _ThunderAnimatedMetadataSegment({this.text}); + /// Optional secondary segment text. final String? text; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.55); + final thunderTheme = ThunderTheme.of(context); + final color = theme.colorScheme.onPrimaryContainer.withValues(alpha: thunderTheme.mutedTextAlpha); return AnimatedSize( duration: const Duration(milliseconds: 250), diff --git a/lib/packages/ui/src/widgets/layout/thunder_section_divider.dart b/lib/packages/ui/src/widgets/layout/thunder_section_divider.dart new file mode 100644 index 000000000..15705e267 --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_section_divider.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Trailing divider used in sidebar-style section headers. +@immutable +class ThunderSectionDivider extends StatelessWidget { + const ThunderSectionDivider({ + super.key, + this.indent, + this.height = 5.0, + this.thickness = 2.0, + }); + + /// Leading indent. Defaults to the theme sidebar indent. + final double? indent; + + /// Divider height. + final double height; + + /// Divider line thickness. + final double thickness; + + @override + Widget build(BuildContext context) { + final thunderTheme = ThunderTheme.of(context); + + return Expanded( + child: Divider( + height: height, + thickness: thickness, + indent: indent ?? thunderTheme.sidebarDividerIndent, + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_section_header.dart b/lib/packages/ui/src/widgets/layout/thunder_section_header.dart new file mode 100644 index 000000000..d6133765a --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_section_header.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/layout/thunder_section_divider.dart'; +import 'package:thunder/packages/ui/src/widgets/layout/thunder_section_title.dart'; + +/// Layout variant for [ThunderSectionHeader]. +enum ThunderSectionHeaderVariant { + /// Compact sidebar header with a trailing divider. + sidebar, + + /// Settings-style header with optional description and actions. + settings, + + /// Sliver header with extra top padding. + sliver, +} + +/// Composable section header for sidebars, settings groups, and sliver lists. +@immutable +class ThunderSectionHeader extends StatelessWidget { + const ThunderSectionHeader({ + super.key, + required this.title, + this.description, + this.actions = const [], + this.variant = ThunderSectionHeaderVariant.settings, + this.padding, + }); + + /// Section title text. + final String title; + + /// Optional description shown below [title]. + final String? description; + + /// Trailing action widgets for the sliver variant. + final List actions; + + /// Layout style for this header. + final ThunderSectionHeaderVariant variant; + + /// Outer padding. Defaults vary by [variant]. + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + switch (variant) { + case ThunderSectionHeaderVariant.sidebar: + return Padding( + padding: padding ?? const EdgeInsets.only(top: 12.0, bottom: 8.0, left: 8.0, right: 8.0), + child: Row( + children: [ + Expanded( + child: ThunderSectionTitle(title: title, description: description), + ), + const ThunderSectionDivider(), + ], + ), + ); + case ThunderSectionHeaderVariant.settings: + return ThunderSectionTitle( + title: title, + description: description, + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + ); + case ThunderSectionHeaderVariant.sliver: + return SliverToBoxAdapter( + child: Padding( + padding: padding ?? const EdgeInsets.only(top: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ThunderSectionTitle( + title: title, + description: description, + ), + ), + ...actions, + ], + ), + ), + ); + } + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_section_title.dart b/lib/packages/ui/src/widgets/layout/thunder_section_title.dart new file mode 100644 index 000000000..d3bb0f9cb --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_section_title.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Section title with optional description for Thunder section headers. +@immutable +class ThunderSectionTitle extends StatelessWidget { + const ThunderSectionTitle({ + super.key, + required this.title, + this.description, + this.titleStyle, + this.descriptionStyle, + this.padding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + }); + + /// Section title text. + final String title; + + /// Optional description shown below [title]. + final String? description; + + /// Custom style for [title]. + final TextStyle? titleStyle; + + /// Custom style for [description]. + final TextStyle? descriptionStyle; + + /// Outer padding around the title column. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + + return Padding( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: titleStyle ?? theme.textTheme.titleMedium), + if (description != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + description!, + style: descriptionStyle ?? + theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w400, + color: theme.colorScheme.onSurface.withValues(alpha: thunderTheme.sectionDescriptionAlpha), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_selectable_tile_shell.dart b/lib/packages/ui/src/widgets/layout/thunder_selectable_tile_shell.dart new file mode 100644 index 000000000..1c0fe3c3b --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_selectable_tile_shell.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Tappable rounded row shell with selection and reorder styling. +@immutable +class ThunderSelectableTileShell extends StatelessWidget { + const ThunderSelectableTileShell({ + super.key, + required this.child, + this.selected = false, + this.reordering = false, + this.selectedColor, + this.onTap, + this.borderRadius, + this.padding = const EdgeInsets.fromLTRB(10.0, 0.0, 10.0, 0.0), + }); + + /// Tile content. + final Widget child; + + /// Whether the tile is in a selected state. + final bool selected; + + /// When true, elevates the tile during reorder. + final bool reordering; + + /// Background color when [selected] is true. + final Color? selectedColor; + + /// Called when the tile is tapped. + final void Function()? onTap; + + /// Corner radius. Defaults to the theme tile radius. + final BorderRadius? borderRadius; + + /// Outer padding around the tile. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final thunderTheme = ThunderTheme.of(context); + final radius = borderRadius ?? thunderTheme.tileBorderRadius; + + return Padding( + padding: padding, + child: Material( + elevation: reordering ? 3.0 : 0.0, + color: selected ? selectedColor ?? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: thunderTheme.selectableTileSelectedAlpha) : Colors.transparent, + borderRadius: radius, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_sidebar_stat.dart b/lib/packages/ui/src/widgets/layout/thunder_sidebar_stat.dart new file mode 100644 index 000000000..d3d2817ea --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_sidebar_stat.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Icon and muted label row for sidebar statistics. +@immutable +class ThunderSidebarStat extends StatelessWidget { + const ThunderSidebarStat({ + super.key, + required this.icon, + required this.label, + }); + + /// Stat icon. + final IconData icon; + + /// Stat label text. + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + final mutedColor = theme.colorScheme.onSurface.withValues(alpha: thunderTheme.mutedTextAlpha); + + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2.0, bottom: 2.0), + child: Icon(icon, size: 18.0, color: mutedColor), + ), + Text( + label, + style: TextStyle(color: theme.textTheme.titleSmall?.color?.withValues(alpha: thunderTheme.mutedTextAlpha)), + ), + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_sliver_adapter.dart b/lib/packages/ui/src/widgets/layout/thunder_sliver_adapter.dart new file mode 100644 index 000000000..ff8dffff9 --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_sliver_adapter.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/layout/thunder_conditional_parent.dart'; + +/// Wraps [child] in a sliver container when [sliver] is true. +@immutable +class ThunderSliverAdapter extends StatelessWidget { + const ThunderSliverAdapter({ + super.key, + required this.sliver, + required this.child, + this.fillRemaining = false, + this.hasScrollBody = false, + }); + + /// When true, wraps [child] in a sliver widget. + final bool sliver; + + /// Content to adapt. + final Widget child; + + /// When [sliver] is true, uses [SliverFillRemaining]. + final bool fillRemaining; + + /// Passed to [SliverFillRemaining] when [fillRemaining] is true. + final bool hasScrollBody; + + @override + Widget build(BuildContext context) { + return ThunderConditionalParent( + condition: sliver, + parentBuilder: (child) { + if (fillRemaining) { + return SliverFillRemaining(hasScrollBody: hasScrollBody, child: child); + } + return SliverToBoxAdapter(child: child); + }, + child: child, + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_split_action_row.dart b/lib/packages/ui/src/widgets/layout/thunder_split_action_row.dart new file mode 100644 index 000000000..719ba1004 --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_split_action_row.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +/// Row with a width-constrained leading slot and trailing action widgets. +@immutable +class ThunderSplitActionRow extends StatelessWidget { + const ThunderSplitActionRow({ + super.key, + required this.leading, + required this.trailing, + this.leadingMaxWidthFraction = 0.6, + }); + + /// Leading content constrained by [leadingMaxWidthFraction]. + final Widget leading; + + /// Trailing action widgets. + final Widget trailing; + + /// Maximum width fraction for [leading]. + final double leadingMaxWidthFraction; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: width * leadingMaxWidthFraction), + child: leading, + ), + trailing, + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/layout/thunder_top_bar_scrim.dart b/lib/packages/ui/src/widgets/layout/thunder_top_bar_scrim.dart new file mode 100644 index 000000000..14b769045 --- /dev/null +++ b/lib/packages/ui/src/widgets/layout/thunder_top_bar_scrim.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// Status-bar fill shown when the top app bar is hidden during scroll. +@immutable +class ThunderTopBarScrim extends StatelessWidget { + const ThunderTopBarScrim({ + super.key, + required this.visible, + this.color, + }); + + /// Whether the scrim is shown. + final bool visible; + + /// Fill color. Defaults to [ColorScheme.surface]. + final Color? color; + + @override + Widget build(BuildContext context) { + if (!visible) return const SizedBox.shrink(); + + final theme = Theme.of(context); + + return Positioned( + child: Container( + height: MediaQuery.paddingOf(context).top, + color: color ?? theme.colorScheme.surface, + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/media/thunder_image_viewer.dart b/lib/packages/ui/src/widgets/media/thunder_image_viewer.dart index 87e39bf27..4a900292d 100644 --- a/lib/packages/ui/src/widgets/media/thunder_image_viewer.dart +++ b/lib/packages/ui/src/widgets/media/thunder_image_viewer.dart @@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_avif/flutter_avif.dart'; +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + /// Describes the image content displayed by a [ThunderImageViewer]. /// /// Create a source with either [ThunderImageViewerSource.network] for cached @@ -14,6 +16,7 @@ import 'package:flutter_avif/flutter_avif.dart'; /// The optional [contentType] can be used to hint the image format when the URL /// alone is not sufficient, such as for AVIF images served without an `.avif` /// file extension. +@immutable sealed class ThunderImageViewerSource { /// Creates a descriptor for image content shown by a /// [ThunderImageViewer]. @@ -36,6 +39,7 @@ sealed class ThunderImageViewerSource { } /// A [ThunderImageViewerSource] backed by image bytes held in memory. +@immutable class ThunderImageViewerMemorySource extends ThunderImageViewerSource { /// Creates a memory-backed image source. const ThunderImageViewerMemorySource(this.bytes, {super.contentType}); @@ -45,6 +49,7 @@ class ThunderImageViewerMemorySource extends ThunderImageViewerSource { } /// A [ThunderImageViewerSource] backed by a network URL. +@immutable class ThunderImageViewerNetworkSource extends ThunderImageViewerSource { /// Creates a network-backed image source. const ThunderImageViewerNetworkSource(this.url, {super.contentType}); @@ -74,7 +79,7 @@ class ThunderImageViewer extends StatefulWidget { const ThunderImageViewer({ super.key, required this.source, - this.backgroundColor = Colors.black, + this.backgroundColor, this.contentSize, this.dismissible = true, this.doubleTapScales = const [2.0, 4.0], @@ -94,7 +99,7 @@ class ThunderImageViewer extends StatefulWidget { /// The color painted behind the image. /// /// This color also fades during a drag-to-dismiss gesture. - final Color backgroundColor; + final Color? backgroundColor; /// The intrinsic size of the image content, if known. /// @@ -138,22 +143,22 @@ class ThunderImageViewer extends StatefulWidget { /// Called when the viewer has been dragged far enough to dismiss. /// /// This callback is only invoked when [dismissible] is true. - final VoidCallback? onDismiss; + final void Function()? onDismiss; /// Called when the viewer detects a long press. - final VoidCallback? onLongPress; + final void Function()? onLongPress; /// Called whenever the effective zoom scale changes. /// /// This includes animated double-tap zoom, double-tap-and-drag zoom, pinch /// gestures, and snap-back corrections after a gesture ends. - final ValueChanged? onScaleChanged; + final void Function(double)? onScaleChanged; /// Called after a completed single tap. /// /// Single taps are delayed slightly so the viewer can distinguish them from a /// double tap. - final VoidCallback? onTap; + final void Function()? onTap; /// The semantic label used for the underlying image. final String? semanticLabel; @@ -167,8 +172,8 @@ class ThunderImageViewer extends StatefulWidget { class _ThunderImageViewerState extends State with TickerProviderStateMixin { static const Duration _doubleTapTimeout = Duration(milliseconds: 280); - static const double _doubleTapDragSlop = 8; - static const double _doubleTapSlop = 36; + static const double _doubleTapDragSlop = 8.0; + static const double _doubleTapSlop = 36.0; static const double _gestureEpsilon = 0.01; late final AnimationController _transformAnimationController; @@ -207,6 +212,7 @@ class _ThunderImageViewerState extends State with TickerProv bool _didTriggerLongPress = false; bool _ignoreScaleEnd = false; bool _tapMoved = false; + bool? _isNetworkAvif; void _setScale(double value) { if ((_scale - value).abs() <= _gestureEpsilon) { @@ -227,6 +233,38 @@ class _ThunderImageViewerState extends State with TickerProv void initState() { super.initState(); _transformAnimationController = AnimationController(vsync: this)..addListener(_handleTransformAnimationTick); + _cacheNetworkAvifHint(); + } + + @override + void didUpdateWidget(ThunderImageViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.source != widget.source) { + _cacheNetworkAvifHint(); + } + } + + void _cacheNetworkAvifHint() { + final source = widget.source; + if (source is ThunderImageViewerNetworkSource) { + final isAvifByUrl = source.url.toLowerCase().endsWith('.avif'); + final isAvifByContentType = source.contentType?.toLowerCase() == 'image/avif'; + _isNetworkAvif = isAvifByUrl || isAvifByContentType; + } else { + _isNetworkAvif = null; + } + } + + void _scheduleViewportSync(Size viewportSize) { + if (_viewportSize == viewportSize) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _viewportSize == viewportSize) return; + setState(() { + _viewportSize = viewportSize; + _baseContentSize = _resolveBaseContentSize(viewportSize); + }); + }); } @override @@ -578,76 +616,24 @@ class _ThunderImageViewerState extends State with TickerProv _gestureMode = _ViewerGestureMode.idle; } - Widget _buildDefaultLoader(BuildContext context) { - return const Center(child: CircularProgressIndicator()); - } - - Widget _buildDefaultError(BuildContext context, Object error) { - return const Center(child: Icon(Icons.broken_image_outlined, color: Colors.white70, size: 36)); - } - - Widget _buildImageWidget() { - final source = widget.source; - - if (source is ThunderImageViewerMemorySource) { - return Image.memory( - source.bytes, - filterQuality: widget.filterQuality, - fit: BoxFit.contain, - semanticLabel: widget.semanticLabel, - ); - } - - final networkSource = source as ThunderImageViewerNetworkSource; - final isAvifByUrl = networkSource.url.toLowerCase().endsWith('.avif'); - final isAvifByContentType = source.contentType?.toLowerCase() == 'image/avif'; - final isAvif = isAvifByUrl || isAvifByContentType; - - if (isAvif) { - return CachedNetworkAvifImage( - networkSource.url, - fit: BoxFit.contain, - filterQuality: widget.filterQuality, - errorBuilder: (context, error, stackTrace) { - return widget.errorBuilder?.call(context, error) ?? _buildDefaultError(context, error); - }, - ); - } - - return CachedNetworkImage( - imageUrl: networkSource.url, - fadeInDuration: const Duration(milliseconds: 100), - fadeOutDuration: Duration.zero, - fit: BoxFit.contain, - imageBuilder: (context, imageProvider) { - return Image( - image: imageProvider, - filterQuality: widget.filterQuality, - fit: BoxFit.contain, - semanticLabel: widget.semanticLabel, - ); - }, - placeholder: (context, url) => widget.loadingBuilder?.call(context) ?? _buildDefaultLoader(context), - errorWidget: (context, url, error) { - return widget.errorBuilder?.call(context, error) ?? _buildDefaultError(context, error); - }, - ); - } - @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); - _baseContentSize = _resolveBaseContentSize(_viewportSize); + final viewportSize = Size(constraints.maxWidth, constraints.maxHeight); + _scheduleViewportSync(viewportSize); - final dismissProgress = _viewportSize == Size.zero ? 0.0 : (_dismissOffset.distance / (_viewportSize.shortestSide * 0.35)).clamp(0.0, 1.0); + final effectiveViewport = _viewportSize == Size.zero ? viewportSize : _viewportSize; + final baseContentSize = _viewportSize == Size.zero ? _resolveBaseContentSize(viewportSize) : _baseContentSize; + + final backgroundColor = widget.backgroundColor ?? ThunderTheme.of(context).viewerBackgroundColor; + final dismissProgress = effectiveViewport == Size.zero ? 0.0 : (_dismissOffset.distance / (effectiveViewport.shortestSide * 0.35)).clamp(0.0, 1.0); final backgroundOpacity = 1.0 - (dismissProgress * 0.75); final imageOpacity = 1.0 - (dismissProgress * 0.35); final visualOffset = (_scale <= widget.minScale + _gestureEpsilon ? Offset.zero : _offset) + _dismissOffset; return ColoredBox( - color: Color.lerp(Colors.transparent, widget.backgroundColor, backgroundOpacity) ?? widget.backgroundColor, + color: Color.lerp(Colors.transparent, backgroundColor, backgroundOpacity) ?? backgroundColor, child: Listener( behavior: HitTestBehavior.opaque, onPointerCancel: _handlePointerCancel, @@ -671,9 +657,16 @@ class _ThunderImageViewerState extends State with TickerProv scale: _scale, child: RepaintBoundary( child: SizedBox( - height: _baseContentSize.height, - width: _baseContentSize.width, - child: _buildImageWidget(), + height: baseContentSize.height, + width: baseContentSize.width, + child: _ThunderImageViewerImage( + source: widget.source, + filterQuality: widget.filterQuality, + semanticLabel: widget.semanticLabel, + loadingBuilder: widget.loadingBuilder, + errorBuilder: widget.errorBuilder, + isNetworkAvif: _isNetworkAvif, + ), ), ), ), @@ -688,3 +681,101 @@ class _ThunderImageViewerState extends State with TickerProv ); } } + +/// Renders the image for [ThunderImageViewer] from its source. +class _ThunderImageViewerImage extends StatelessWidget { + const _ThunderImageViewerImage({ + required this.source, + required this.filterQuality, + required this.semanticLabel, + required this.loadingBuilder, + required this.errorBuilder, + required this.isNetworkAvif, + }); + + /// The image source to display. + final ThunderImageViewerSource source; + + /// The filter quality used when painting the image. + final FilterQuality filterQuality; + + /// The semantic label for the image. + final String? semanticLabel; + + /// Builds a widget shown while a network image is loading. + final WidgetBuilder? loadingBuilder; + + /// Builds a widget shown when the image fails to load. + final Widget Function(BuildContext context, Object error)? errorBuilder; + + /// Whether the network source should be treated as AVIF. + final bool? isNetworkAvif; + + @override + Widget build(BuildContext context) { + if (source is ThunderImageViewerMemorySource) { + final memorySource = source as ThunderImageViewerMemorySource; + return Image.memory( + memorySource.bytes, + filterQuality: filterQuality, + fit: BoxFit.contain, + semanticLabel: semanticLabel, + ); + } + + final networkSource = source as ThunderImageViewerNetworkSource; + if (isNetworkAvif == true) { + return CachedNetworkAvifImage( + networkSource.url, + fit: BoxFit.contain, + filterQuality: filterQuality, + errorBuilder: (context, error, stackTrace) { + return errorBuilder?.call(context, error) ?? _ThunderImageViewerError(errorBuilder: errorBuilder); + }, + ); + } + + return CachedNetworkImage( + imageUrl: networkSource.url, + fadeInDuration: const Duration(milliseconds: 100), + fadeOutDuration: Duration.zero, + fit: BoxFit.contain, + imageBuilder: (context, imageProvider) { + return Image( + image: imageProvider, + filterQuality: filterQuality, + fit: BoxFit.contain, + semanticLabel: semanticLabel, + ); + }, + placeholder: (context, url) => loadingBuilder?.call(context) ?? const _ThunderImageViewerLoader(), + errorWidget: (context, url, error) { + return errorBuilder?.call(context, error) ?? _ThunderImageViewerError(errorBuilder: errorBuilder); + }, + ); + } +} + +/// Default loading indicator for [ThunderImageViewer]. +class _ThunderImageViewerLoader extends StatelessWidget { + const _ThunderImageViewerLoader(); + + @override + Widget build(BuildContext context) { + return const Center(child: CircularProgressIndicator()); + } +} + +/// Default error indicator for [ThunderImageViewer]. +class _ThunderImageViewerError extends StatelessWidget { + const _ThunderImageViewerError({this.errorBuilder}); + + /// Optional custom error builder. + final Widget Function(BuildContext context, Object error)? errorBuilder; + + @override + Widget build(BuildContext context) { + final thunderTheme = ThunderTheme.of(context); + return Center(child: Icon(Icons.broken_image_outlined, color: thunderTheme.viewerErrorIconColor, size: 36.0)); + } +} diff --git a/lib/packages/ui/src/widgets/media/thunder_media_preview_error.dart b/lib/packages/ui/src/widgets/media/thunder_media_preview_error.dart new file mode 100644 index 000000000..e64f8dfa6 --- /dev/null +++ b/lib/packages/ui/src/widgets/media/thunder_media_preview_error.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Error or retry placeholder shown when media preview loading fails. +/// +/// When [canRetry] is true and [onRetry] is provided, a refresh icon is shown +/// and [icon] is ignored. +@immutable +class ThunderMediaPreviewError extends StatelessWidget { + const ThunderMediaPreviewError({ + super.key, + required this.icon, + this.blur = false, + this.viewed = false, + this.canRetry = false, + this.onRetry, + this.retryTooltip = 'Retry', + }); + + /// Icon shown when retry is unavailable or [onRetry] is null. + final IconData icon; + + /// When true, renders nothing. + final bool blur; + + /// When true, mutes the icon color. + final bool viewed; + + /// Whether to show a retry affordance when [onRetry] is set. + final bool canRetry; + + /// Called when the retry affordance is tapped. + final void Function()? onRetry; + + /// Tooltip for the retry icon. + final String retryTooltip; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + + if (blur) return const SizedBox.shrink(); + + final iconColor = theme.colorScheme.onSecondaryContainer.withValues( + alpha: viewed ? thunderTheme.mutedTextAlpha : 1.0, + ); + + if (canRetry && onRetry != null) { + return GestureDetector( + onTap: onRetry, + behavior: HitTestBehavior.opaque, + child: Center( + child: Tooltip( + message: retryTooltip, + child: Icon(Icons.refresh_rounded, color: iconColor), + ), + ), + ); + } + + return Center( + child: Icon(icon, color: iconColor), + ); + } +} diff --git a/lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart b/lib/packages/ui/src/widgets/navigation/thunder_bottom_navigation_bar.dart similarity index 96% rename from lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart rename to lib/packages/ui/src/widgets/navigation/thunder_bottom_navigation_bar.dart index 6880ea205..c6d3d5d2f 100644 --- a/lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart +++ b/lib/packages/ui/src/widgets/navigation/thunder_bottom_navigation_bar.dart @@ -16,7 +16,7 @@ class ThunderBottomNavigationBar extends StatefulWidget { this.longPressSuppressionDuration = const Duration(seconds: 1), this.onHorizontalSwipeLeft, this.onHorizontalSwipeRight, - this.horizontalSwipeThreshold = 20, + this.horizontalSwipeThreshold = 20.0, this.onDoubleTap, }); @@ -27,13 +27,13 @@ class ThunderBottomNavigationBar extends StatefulWidget { final List destinations; /// Callback invoked when a destination is selected (tapped). - final ValueChanged onDestinationSelected; + final void Function(int) onDestinationSelected; /// Controls the visibility of destination labels. final NavigationDestinationLabelBehavior? labelBehavior; /// Long-press callbacks keyed by destination index. - final Map onDestinationLongPresses; + final Map onDestinationLongPresses; /// How long a press must be held before the destination long-press fires. final Duration longPressTimeout; @@ -42,16 +42,16 @@ class ThunderBottomNavigationBar extends StatefulWidget { final Duration longPressSuppressionDuration; /// Callback invoked when the bar is swiped left past the threshold. - final VoidCallback? onHorizontalSwipeLeft; + final void Function()? onHorizontalSwipeLeft; /// Callback invoked when the bar is swiped right past the threshold. - final VoidCallback? onHorizontalSwipeRight; + final void Function()? onHorizontalSwipeRight; /// Minimum horizontal drag delta before a swipe callback is fired. final double horizontalSwipeThreshold; /// Callback invoked when the bar is double-tapped. - final VoidCallback? onDoubleTap; + final void Function()? onDoubleTap; @override State createState() => _ThunderBottomNavigationBarState(); diff --git a/lib/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart b/lib/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart deleted file mode 100644 index da78ad037..000000000 --- a/lib/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter/material.dart'; -import 'package:thunder/packages/ui/src/widgets/pickers/picker_item.dart'; - -class BottomSheetListPicker extends StatefulWidget { - final String title; - final List> items; - final Future Function(PickerOption)? onSelect; - final T? previouslySelected; - final bool closeOnSelect; - final Widget? heading; - final Widget Function()? onUpdateHeading; - final String saveButtonLabel; - - const BottomSheetListPicker({ - super.key, - required this.title, - required this.items, - this.onSelect, - this.previouslySelected, - this.closeOnSelect = true, - this.heading, - this.onUpdateHeading, - this.saveButtonLabel = 'Save', - }); - - @override - State createState() => _BottomSheetListPickerState(); -} - -class _BottomSheetListPickerState extends State> { - T? currentlySelected; - Widget? heading; - - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Stack( - children: [ - Padding( - padding: EdgeInsets.only(bottom: widget.closeOnSelect ? 0 : 100), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - if (widget.title.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 16.0, left: 26.0, right: 16.0), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - widget.title, - style: theme.textTheme.titleLarge!.copyWith(), - ), - ), - ), - if ((heading ?? widget.heading) != null) - Padding( - padding: const EdgeInsets.only(left: 24, right: 24, bottom: 10), - child: (heading ?? widget.heading)!, - ), - ListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: widget.items.map( - (item) { - if (item.customWidget != null) { - return item.customWidget!; - } - - return PickerItem( - label: item.capitalizeLabel ? item.label.capitalize : item.label, - labelWidget: item.labelWidget, - subtitle: item.subtitle, - subtitleWidget: item.subtitleWidget, - icon: item.icon, - textTheme: item.textTheme, - onSelected: () async { - if (widget.closeOnSelect) { - Navigator.of(context).pop(); - } else { - setState(() { - if (item.isChecked == null) { - currentlySelected = item.payload; - } else { - setState(() {}); - } - }); - } - await widget.onSelect?.call(item); - setState(() => heading = widget.onUpdateHeading?.call()); - }, - isSelected: currentlySelected != null ? currentlySelected == item.payload : widget.previouslySelected == item.payload, - leading: Stack( - children: [ - Container( - height: 32, - width: 32, - decoration: BoxDecoration( - color: item.colors?[0], - borderRadius: BorderRadius.circular(100), - ), - ), - Positioned( - bottom: 0, - child: Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: item.colors?[1], - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(100), - ), - ), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: item.colors?[2], - borderRadius: const BorderRadius.only( - bottomRight: Radius.circular(100), - ), - ), - ), - ), - ], - ), - trailingIcon: switch (item.isChecked?.call()) { - true => Icons.check_box_rounded, - false => Icons.check_box_outline_blank_rounded, - null => null, - }, - softWrap: item.softWrap, - ); - }, - ).toList(), - ), - const SizedBox(height: 16.0), - ], - ), - ), - ), - if (!widget.closeOnSelect) - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 50), - child: TextButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(60), - backgroundColor: theme.colorScheme.primaryContainer, - ), - child: Text( - widget.saveButtonLabel, - style: TextStyle(color: theme.colorScheme.onPrimaryContainer), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - ), - ], - ); - } -} - -class PickerOption { - /// Icon shown on the left - final IconData? icon; - - /// When passed in, the left icon will show a color palette - final List? colors; - - /// The label of the item - final String label; - - /// The theme of the label - final TextTheme? textTheme; - - /// The subtitle of the item - final String? subtitle; - - /// Customize the subtitle by providing the whole widget - final Widget? subtitleWidget; - - /// Whether to capitalize the label - final bool capitalizeLabel; - - /// A custom widget to show instead of the label - final Widget? labelWidget; - - /// A custom widget to use instead of the default - final Widget? customWidget; - - /// The payload of the item - final T payload; - - /// Whether the item is selected - final bool Function()? isChecked; - - /// Whether the subtitle should softwrap - final bool softWrap; - - const PickerOption({ - this.icon, - this.colors, - this.label = "", - this.textTheme, - this.subtitle, - this.subtitleWidget, - this.capitalizeLabel = true, - this.labelWidget, - this.customWidget, - required this.payload, - this.isChecked, - this.softWrap = false, - }); -} - -typedef ListPickerItem = PickerOption; diff --git a/lib/packages/ui/src/widgets/pickers/show_thunder_list_picker.dart b/lib/packages/ui/src/widgets/pickers/show_thunder_list_picker.dart new file mode 100644 index 000000000..a5ebeafe6 --- /dev/null +++ b/lib/packages/ui/src/widgets/pickers/show_thunder_list_picker.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/pickers/thunder_bottom_sheet_list_picker.dart'; + +/// Presents a modal bottom sheet list picker for a single value. +/// +/// When [customPicker] is provided, it is shown instead of +/// [ThunderBottomSheetListPicker]. +Future showThunderListPicker({ + required BuildContext context, + required String title, + required List> items, + required ThunderListPickerItem selected, + Future Function(ThunderListPickerItem)? onSelect, + Widget? heading, + Widget Function()? onUpdateHeading, + bool closeOnSelect = true, + bool isScrollControlled = false, + String saveButtonLabel = 'Save', + Widget? customPicker, +}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: isScrollControlled, + builder: (context) { + if (customPicker != null) return customPicker; + + return ThunderBottomSheetListPicker( + title: title, + heading: heading, + onUpdateHeading: onUpdateHeading, + items: items, + onSelect: onSelect ?? (_) async {}, + previouslySelected: selected.payload, + closeOnSelect: closeOnSelect, + saveButtonLabel: saveButtonLabel, + ); + }, + ); +} diff --git a/lib/packages/ui/src/widgets/pickers/thunder_bottom_sheet_list_picker.dart b/lib/packages/ui/src/widgets/pickers/thunder_bottom_sheet_list_picker.dart new file mode 100644 index 000000000..05e460030 --- /dev/null +++ b/lib/packages/ui/src/widgets/pickers/thunder_bottom_sheet_list_picker.dart @@ -0,0 +1,281 @@ +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/layout/thunder_bottom_sheet.dart'; +import 'package:thunder/packages/ui/src/widgets/pickers/thunder_picker_item.dart'; + +/// Scrollable list picker presented inside a Thunder bottom sheet. +class ThunderBottomSheetListPicker extends StatefulWidget { + const ThunderBottomSheetListPicker({ + super.key, + required this.title, + required this.items, + this.onSelect, + this.previouslySelected, + this.closeOnSelect = true, + this.heading, + this.onUpdateHeading, + this.saveButtonLabel = 'Save', + }); + + /// Title shown in the sheet header. Hidden when empty. + final String title; + + /// Picker rows to display. + final List> items; + + /// Called when an item is selected. + final Future Function(ThunderPickerOption)? onSelect; + + /// Previously selected payload used to mark the initial selection. + final T? previouslySelected; + + /// When true, the sheet closes immediately after a selection. + final bool closeOnSelect; + + /// Optional heading widget shown below the title. + final Widget? heading; + + /// Rebuilds [heading] after selection when [closeOnSelect] is false. + final Widget Function()? onUpdateHeading; + + /// Label for the save button shown when [closeOnSelect] is false. + final String saveButtonLabel; + + @override + State createState() => _ThunderBottomSheetListPickerState(); +} + +class _ThunderBottomSheetListPickerState extends State> { + T? currentlySelected; + Widget? heading; + + @override + void initState() { + super.initState(); + currentlySelected = widget.previouslySelected; + } + + Future _onItemSelected(ThunderPickerOption item) async { + if (widget.closeOnSelect) { + Navigator.of(context).pop(); + } else { + setState(() { + if (item.isChecked == null) { + currentlySelected = item.payload; + } + }); + } + await widget.onSelect?.call(item); + if (!widget.closeOnSelect) { + setState(() => heading = widget.onUpdateHeading?.call()); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final displayHeading = heading ?? widget.heading; + + return Stack( + children: [ + Padding( + padding: EdgeInsets.only(bottom: widget.closeOnSelect ? 0.0 : 100.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.title.isNotEmpty) ThunderBottomSheetHeader(title: widget.title), + if (displayHeading != null) + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 10.0), + child: displayHeading, + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.items.length, + itemBuilder: (context, index) => _ThunderBottomSheetListPickerItem( + item: widget.items[index], + isSelected: currentlySelected != null ? currentlySelected == widget.items[index].payload : widget.previouslySelected == widget.items[index].payload, + onSelected: () => _onItemSelected(widget.items[index]), + ), + ), + ), + const SizedBox(height: 16.0), + ], + ), + ), + if (!widget.closeOnSelect) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 50.0), + child: TextButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(60), + backgroundColor: theme.colorScheme.primaryContainer, + ), + child: Text( + widget.saveButtonLabel, + style: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + ], + ); + } +} + +/// Renders one row in [ThunderBottomSheetListPicker]. +class _ThunderBottomSheetListPickerItem extends StatelessWidget { + const _ThunderBottomSheetListPickerItem({ + required this.item, + required this.isSelected, + required this.onSelected, + }); + + /// The picker option to display. + final ThunderPickerOption item; + + /// Whether this item is currently selected. + final bool isSelected; + + /// Called when the item is tapped. + final void Function() onSelected; + + @override + Widget build(BuildContext context) { + if (item.customWidget != null) { + return item.customWidget!; + } + + return ThunderPickerItem( + label: item.capitalizeLabel ? item.label.capitalize : item.label, + labelWidget: item.labelWidget, + subtitle: item.subtitle, + subtitleWidget: item.subtitleWidget, + icon: item.icon, + textTheme: item.textTheme, + onSelected: onSelected, + isSelected: isSelected, + leading: item.colors != null ? _ThunderColorPaletteLeading(colors: item.colors!) : null, + trailingIcon: switch (item.isChecked?.call()) { + true => Icons.check_box_rounded, + false => Icons.check_box_outline_blank_rounded, + null => null, + }, + softWrap: item.softWrap, + ); + } +} + +/// Leading color palette icon for picker items with theme colors. +class _ThunderColorPaletteLeading extends StatelessWidget { + const _ThunderColorPaletteLeading({required this.colors}); + + /// Colors used to render the palette circles. + final List colors; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + height: 32.0, + width: 32.0, + decoration: BoxDecoration( + color: colors.elementAtOrNull(0), + borderRadius: BorderRadius.circular(100.0), + ), + ), + Positioned( + bottom: 0, + child: Container( + height: 16.0, + width: 16.0, + decoration: BoxDecoration( + color: colors.elementAtOrNull(1), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(100.0), + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + height: 16.0, + width: 16.0, + decoration: BoxDecoration( + color: colors.elementAtOrNull(2), + borderRadius: const BorderRadius.only( + bottomRight: Radius.circular(100.0), + ), + ), + ), + ), + ], + ); + } +} + +/// Option shown in [ThunderBottomSheetListPicker]. +@immutable +class ThunderPickerOption { + const ThunderPickerOption({ + this.icon, + this.colors, + this.label = "", + this.textTheme, + this.subtitle, + this.subtitleWidget, + this.capitalizeLabel = true, + this.labelWidget, + this.customWidget, + required this.payload, + this.isChecked, + this.softWrap = false, + }); + + /// Icon shown on the left. + final IconData? icon; + + /// When passed in, the left icon will show a color palette. + final List? colors; + + /// The label of the item. + final String label; + + /// The theme of the label. + final TextTheme? textTheme; + + /// The subtitle of the item. + final String? subtitle; + + /// Customize the subtitle by providing the whole widget. + final Widget? subtitleWidget; + + /// Whether to capitalize the label. + final bool capitalizeLabel; + + /// A custom widget to show instead of the label. + final Widget? labelWidget; + + /// A custom widget to use instead of the default. + final Widget? customWidget; + + /// The payload of the item. + final T payload; + + /// Whether the item is selected. + final bool Function()? isChecked; + + /// Whether the subtitle should softwrap. + final bool softWrap; +} + +/// Alias for [ThunderPickerOption] used by list picker call sites. +typedef ThunderListPickerItem = ThunderPickerOption; diff --git a/lib/packages/ui/src/widgets/pickers/multi_picker_item.dart b/lib/packages/ui/src/widgets/pickers/thunder_multi_picker_item.dart similarity index 58% rename from lib/packages/ui/src/widgets/pickers/multi_picker_item.dart rename to lib/packages/ui/src/widgets/pickers/thunder_multi_picker_item.dart index 2e86259f2..57b24d71f 100644 --- a/lib/packages/ui/src/widgets/pickers/multi_picker_item.dart +++ b/lib/packages/ui/src/widgets/pickers/thunder_multi_picker_item.dart @@ -1,55 +1,68 @@ -import 'package:flutter/material.dart'; - -class PickerItemData { - final String label; - final IconData? icon; - final Color? backgroundColor; - final Color? foregroundColor; - final void Function()? onSelected; - - const PickerItemData({ - required this.label, - required this.icon, - this.backgroundColor, - this.foregroundColor, - required this.onSelected, - }); -} - -/// Defines a widget with a row of buttons -class MultiPickerItem extends StatelessWidget { - final List pickerItems; - - const MultiPickerItem({super.key, required this.pickerItems}); - - @override - Widget build(BuildContext context) { - ThemeData theme = Theme.of(context); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ...pickerItems.map( - (p) => Expanded( - flex: 1, - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 10), - child: Tooltip( - message: p.label, - child: TextButton( - onPressed: p.onSelected, - style: TextButton.styleFrom(foregroundColor: p.backgroundColor), - child: Icon( - p.icon, - size: 24, - semanticLabel: p.label, - color: p.onSelected == null ? null : p.foregroundColor ?? theme.textTheme.bodyMedium?.color, - ), - ), - ), - ), - ), - ), - ], - ); - } -} +import 'package:flutter/material.dart'; + +/// Configuration for a single button in [ThunderMultiPickerItem]. +@immutable +class ThunderMultiPickerItemData { + const ThunderMultiPickerItemData({ + required this.label, + required this.icon, + this.backgroundColor, + this.foregroundColor, + required this.onSelected, + }); + + /// Button label used for tooltips and semantics. + final String label; + + /// Icon shown on the button. + final IconData? icon; + + /// Background color passed to the button style foreground. + final Color? backgroundColor; + + /// Icon color when the button is enabled. + final Color? foregroundColor; + + /// Called when the button is pressed. + final void Function()? onSelected; +} + +/// Row of evenly spaced picker buttons with tooltips. +@immutable +class ThunderMultiPickerItem extends StatelessWidget { + const ThunderMultiPickerItem({super.key, required this.pickerItems}); + + /// Button definitions rendered left to right. + final List pickerItems; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ...pickerItems.map( + (p) => Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0), + child: Tooltip( + message: p.label, + child: TextButton( + onPressed: p.onSelected, + style: TextButton.styleFrom(foregroundColor: p.backgroundColor), + child: Icon( + p.icon, + size: 24.0, + semanticLabel: p.label, + color: p.onSelected == null ? null : p.foregroundColor ?? theme.textTheme.bodyMedium?.color, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/pickers/picker_item.dart b/lib/packages/ui/src/widgets/pickers/thunder_picker_item.dart similarity index 60% rename from lib/packages/ui/src/widgets/pickers/picker_item.dart rename to lib/packages/ui/src/widgets/pickers/thunder_picker_item.dart index 421f84401..86a25ef2e 100644 --- a/lib/packages/ui/src/widgets/pickers/picker_item.dart +++ b/lib/packages/ui/src/widgets/pickers/thunder_picker_item.dart @@ -1,19 +1,11 @@ import 'package:flutter/material.dart'; -class PickerItem extends StatelessWidget { - final String label; - final String? subtitle; - final Widget? subtitleWidget; - final Widget? labelWidget; - final IconData? icon; - final Widget? leading; - final IconData? trailingIcon; - final void Function()? onSelected; - final bool? isSelected; - final TextTheme? textTheme; - final bool softWrap; +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; - const PickerItem({ +/// Selectable list tile used in Thunder pickers and bottom sheet lists. +@immutable +class ThunderPickerItem extends StatelessWidget { + const ThunderPickerItem({ super.key, required this.label, this.subtitle, @@ -28,23 +20,59 @@ class PickerItem extends StatelessWidget { this.softWrap = false, }); + /// Primary label text. + final String label; + + /// Optional subtitle shown below [label]. + final String? subtitle; + + /// Custom subtitle widget that replaces [subtitle] when provided. + final Widget? subtitleWidget; + + /// Custom title widget that replaces [label] when provided. + final Widget? labelWidget; + + /// Optional leading icon. + final IconData? icon; + + /// Custom leading widget that replaces [icon] when provided. + final Widget? leading; + + /// Optional trailing icon, such as a check mark. + final IconData? trailingIcon; + + /// Called when the tile is tapped. + final void Function()? onSelected; + + /// Whether the tile is currently selected. + final bool? isSelected; + + /// Optional text theme override for title and subtitle. + final TextTheme? textTheme; + + /// Whether the subtitle should wrap to multiple lines. + final bool softWrap; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + final tileBorderRadius = thunderTheme.tileBorderRadius; + return Padding( - padding: const EdgeInsets.only(left: 10, right: 10), + padding: const EdgeInsets.only(left: 10.0, right: 10.0), child: Material( - borderRadius: BorderRadius.circular(50), - color: isSelected == true ? theme.colorScheme.primaryContainer.withValues(alpha: 0.25) : Colors.transparent, + borderRadius: tileBorderRadius, + color: isSelected == true ? theme.colorScheme.primaryContainer.withValues(alpha: thunderTheme.pickerSelectedAlpha) : Colors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(50), + borderRadius: tileBorderRadius, onTap: onSelected, child: ListTile( title: labelWidget ?? Text( label, style: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.copyWith( - color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withValues(alpha: onSelected == null ? 0.5 : 1), + color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withValues(alpha: onSelected == null ? thunderTheme.settingsTileDisabledAlpha : 1), ), textScaler: TextScaler.noScaling, ), @@ -53,7 +81,7 @@ class PickerItem extends StatelessWidget { ? Text( subtitle!, style: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.copyWith( - color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withValues(alpha: 0.5), + color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withValues(alpha: thunderTheme.settingsTileDisabledAlpha), ), softWrap: softWrap, overflow: TextOverflow.fade, diff --git a/lib/packages/ui/src/widgets/settings/thunder_expandable_option.dart b/lib/packages/ui/src/widgets/settings/thunder_expandable_option.dart index a391c1e55..1f234f676 100644 --- a/lib/packages/ui/src/widgets/settings/thunder_expandable_option.dart +++ b/lib/packages/ui/src/widgets/settings/thunder_expandable_option.dart @@ -1,17 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/src/widgets/settings/thunder_settings_tile.dart'; +import 'package:thunder/packages/ui/src/widgets/settings/thunder_settings_trailing.dart'; + +/// Expandable settings section with a chevron header and animated child content. class ThunderExpandableOption extends StatefulWidget { const ThunderExpandableOption({ super.key, - this.icon, + this.leading, required this.title, required this.child, this.initiallyExpanded = false, }); - final Widget? icon; + /// Optional leading widget shown in the header tile. + final Widget? leading; + + /// Header title text. final String title; + + /// Content revealed when the section is expanded. final Widget child; + + /// Whether the section starts expanded. final bool initiallyExpanded; @override @@ -45,34 +56,13 @@ class _ThunderExpandableOptionState extends State with @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Column( children: [ - InkWell( - borderRadius: const BorderRadius.all(Radius.circular(50)), + ThunderSettingsTile( + title: widget.title, + leading: widget.leading, + trailing: ThunderSettingsExpandTrailing(expanded: _isExpanded), onTap: () => setState(() => _isExpanded = !_isExpanded), - child: Padding( - padding: const EdgeInsets.only(left: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - children: [ - if (widget.icon != null) ...[ - widget.icon!, - const SizedBox(width: 8), - ], - Expanded(child: Text(widget.title, style: theme.textTheme.bodyMedium)), - ], - ), - ), - const SizedBox(height: 40), - Icon(_isExpanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded), - ], - ), - ), ), AnimatedSwitcher( duration: const Duration(milliseconds: 250), @@ -86,7 +76,7 @@ class _ThunderExpandableOptionState extends State with }, child: _isExpanded ? Padding( - padding: const EdgeInsets.all(6), + padding: const EdgeInsets.all(6.0), child: widget.child, ) : const SizedBox.shrink(), diff --git a/lib/packages/ui/src/widgets/settings/thunder_list_option.dart b/lib/packages/ui/src/widgets/settings/thunder_list_option.dart index 1044f05b6..e40dc5420 100644 --- a/lib/packages/ui/src/widgets/settings/thunder_list_option.dart +++ b/lib/packages/ui/src/widgets/settings/thunder_list_option.dart @@ -1,10 +1,21 @@ -import 'package:flutter/material.dart'; - import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; -import 'package:thunder/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart'; +import 'package:thunder/packages/ui/src/widgets/pickers/thunder_bottom_sheet_list_picker.dart'; +import 'package:thunder/packages/ui/src/widgets/pickers/show_thunder_list_picker.dart'; import 'package:thunder/packages/ui/src/widgets/settings/thunder_settings_tile.dart'; +import 'package:thunder/packages/ui/src/widgets/settings/thunder_settings_trailing.dart'; +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +final RegExp _listOptionLabelSpacingPattern = RegExp(r'([A-Z])'); + +String _formatListOptionLabel(ThunderListPickerItem value) { + if (!value.capitalizeLabel) return value.label; + return value.label.capitalize.replaceAll('_', '').replaceAll(' ', '').replaceAllMapped(_listOptionLabelSpacingPattern, (match) => ' ${match.group(0)}'); +} +/// Settings tile that opens a bottom sheet list picker for a single value. +@immutable class ThunderListOption extends StatelessWidget { const ThunderListOption({ super.key, @@ -30,50 +41,74 @@ class ThunderListOption extends StatelessWidget { this.saveButtonLabel = 'Save', }); + /// Primary title text. final String title; + + /// Optional subtitle shown below [title]. final String? subtitle; + + /// Custom subtitle widget that replaces [subtitle] when provided. final Widget? subtitleWidget; + + /// Widget shown before the title column. final Widget? leading; + + /// Optional heading widget shown at the top of the picker sheet. final Widget? bottomSheetHeading; - final ListPickerItem value; - final List> options; - final Future Function(ListPickerItem)? onChanged; + + /// Currently selected value. + final ThunderListPickerItem value; + + /// Available picker options. + final List> options; + + /// Called when the user selects a new value. + final Future Function(ThunderListPickerItem)? onChanged; + + /// Custom picker widget used instead of [ThunderBottomSheetListPicker]. final Widget? customListPicker; + + /// Whether the bottom sheet should be scroll controlled. final bool? isBottomModalScrollControlled; + + /// When true, the tile cannot be tapped. final bool disabled; + + /// Custom widget shown instead of the default value label. final Widget? valueDisplay; + + /// Whether the sheet closes immediately after a selection. final bool closeOnSelect; + + /// Rebuilds the heading after selection when [closeOnSelect] is false. final Widget Function()? onUpdateHeading; + + /// Whether to show the smooth highlight animation. final bool highlighted; + + /// Key attached to the highlight widget when [highlighted] is true. final GlobalKey? highlightKey; + + /// Highlight color passed to [ThunderSettingsTile]. final Color? highlightColor; - final VoidCallback? onLongPress; + + /// Called when the tile is long-pressed. + final void Function()? onLongPress; + + /// Semantic label for accessibility. final String? semanticLabel; + + /// Label for the save button when [closeOnSelect] is false. final String saveButtonLabel; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final trailing = Row( + mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - valueDisplay ?? - Text( - value.capitalizeLabel - ? value.label.capitalize.replaceAll('_', '').replaceAll(' ', '').replaceAllMapped(RegExp(r'([A-Z])'), (match) { - return ' ${match.group(0)}'; - }) - : value.label, - style: theme.textTheme.titleSmall?.copyWith( - color: disabled ? theme.colorScheme.onSurface.withValues(alpha: 0.5) : theme.colorScheme.onSurface, - ), - ), - Icon( - Icons.chevron_right_rounded, - color: disabled ? theme.colorScheme.onSurface.withValues(alpha: 0.5) : null, - ), - const SizedBox(height: 42), + valueDisplay ?? _ThunderListOptionValueLabel(value: value, disabled: disabled), + ThunderSettingsChevronTrailing(disabled: disabled), ], ); @@ -91,28 +126,46 @@ class ThunderListOption extends StatelessWidget { onLongPress: disabled ? null : onLongPress, onTap: disabled ? null - : () { - showModalBottomSheet( + : () => showThunderListPicker( context: context, - showDragHandle: true, + title: title, + items: options, + selected: value, + onSelect: onChanged, + heading: bottomSheetHeading, + onUpdateHeading: onUpdateHeading, + closeOnSelect: closeOnSelect, isScrollControlled: isBottomModalScrollControlled ?? false, - builder: (context) { - if (customListPicker != null) return customListPicker!; - - return BottomSheetListPicker( - title: title, - heading: bottomSheetHeading, - onUpdateHeading: onUpdateHeading, - items: options, - onSelect: onChanged ?? (_) async {}, - previouslySelected: value.payload, - closeOnSelect: closeOnSelect, - saveButtonLabel: saveButtonLabel, - ); - }, - ); - }, + saveButtonLabel: saveButtonLabel, + customPicker: customListPicker, + ), subtitleMaxLines: subtitleWidget == null ? null : 1, ); } } + +/// Trailing value label for [ThunderListOption]. +class _ThunderListOptionValueLabel extends StatelessWidget { + const _ThunderListOptionValueLabel({required this.value, required this.disabled}); + + /// The currently selected picker item. + final ThunderListPickerItem value; + + /// Whether the parent tile is disabled. + final bool disabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + final label = _formatListOptionLabel(value); + + return Text( + label, + textAlign: TextAlign.right, + style: theme.textTheme.titleSmall?.copyWith( + color: disabled ? theme.colorScheme.onSurface.withValues(alpha: thunderTheme.settingsTileDisabledAlpha) : theme.colorScheme.onSurface, + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/settings/thunder_settings_tile.dart b/lib/packages/ui/src/widgets/settings/thunder_settings_tile.dart index ed44c4cb2..deaeea996 100644 --- a/lib/packages/ui/src/widgets/settings/thunder_settings_tile.dart +++ b/lib/packages/ui/src/widgets/settings/thunder_settings_tile.dart @@ -2,10 +2,15 @@ import 'package:flutter/material.dart'; import 'package:smooth_highlight/smooth_highlight.dart'; +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Base settings list tile with optional highlight, subtitle, and trailing widgets. +@immutable class ThunderSettingsTile extends StatelessWidget { const ThunderSettingsTile({ super.key, required this.title, + this.titleWidget, this.subtitle, this.subtitleWidget, this.leading, @@ -21,28 +26,65 @@ class ThunderSettingsTile extends StatelessWidget { this.enabled = true, }); + /// Primary title text. final String title; + + /// Custom title widget that replaces [title] when provided. + final Widget? titleWidget; + + /// Optional subtitle shown below the title. final String? subtitle; + + /// Custom subtitle widget that replaces [subtitle] when provided. final Widget? subtitleWidget; + + /// Widget shown before the title column. final Widget? leading; + + /// Widget shown after the title column. final Widget? trailing; - final VoidCallback? onTap; - final VoidCallback? onLongPress; + + /// Called when the tile is tapped. + final void Function()? onTap; + + /// Called when the tile is long-pressed. + final void Function()? onLongPress; + + /// Semantic label for accessibility. Defaults to [title]. final String? semanticLabel; + + /// Maximum lines for [subtitle]. final int? subtitleMaxLines; + + /// Outer padding around the tile content. final EdgeInsetsGeometry? padding; + + /// Whether to show the smooth highlight animation. final bool highlighted; + + /// Key attached to the highlight widget when [highlighted] is true. final GlobalKey? highlightKey; + + /// Highlight color. Defaults to [ColorScheme.primaryContainer]. final Color? highlightColor; + + /// When false, tap and long-press handlers are disabled and styles are muted. final bool enabled; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); final bool interactive = enabled && (onTap != null || onLongPress != null); final subtitleStyle = theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.8), + color: theme.textTheme.bodySmall?.color?.withValues(alpha: thunderTheme.settingsTileSubtitleAlpha), ); + final titleStyle = interactive + ? theme.textTheme.bodyMedium + : theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withValues(alpha: thunderTheme.settingsTileDisabledAlpha), + ); + final tileBorderRadius = thunderTheme.tileBorderRadius; return SmoothHighlight( key: highlighted ? highlightKey : null, @@ -50,15 +92,15 @@ class ThunderSettingsTile extends StatelessWidget { enabled: highlighted, color: highlightColor ?? theme.colorScheme.primaryContainer, child: Padding( - padding: padding ?? const EdgeInsets.symmetric(horizontal: 16), + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16.0), child: Semantics( label: semanticLabel ?? title, child: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(50)), + borderRadius: tileBorderRadius, onTap: interactive ? onTap : null, onLongPress: interactive ? onLongPress : null, child: Padding( - padding: const EdgeInsets.only(left: 4), + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 2.0, bottom: 2.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -67,20 +109,13 @@ class ThunderSettingsTile extends StatelessWidget { children: [ if (leading != null) ...[ leading!, - const SizedBox(width: 8), + SizedBox(width: thunderTheme.settingsTileLeadingGap), ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: interactive - ? theme.textTheme.bodyMedium - : theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5), - ), - ), + titleWidget ?? Text(title, style: titleStyle), if (subtitleWidget != null) subtitleWidget!, if (subtitle != null) Text( diff --git a/lib/packages/ui/src/widgets/settings/thunder_settings_trailing.dart b/lib/packages/ui/src/widgets/settings/thunder_settings_trailing.dart new file mode 100644 index 000000000..9dc81a413 --- /dev/null +++ b/lib/packages/ui/src/widgets/settings/thunder_settings_trailing.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/theme/thunder_theme.dart'; + +/// Fixed-size trailing slot used by settings tile controls. +@immutable +class ThunderSettingsTrailingSlot extends StatelessWidget { + const ThunderSettingsTrailingSlot({ + super.key, + required this.child, + this.width, + this.height, + }); + + /// Content aligned inside the slot. + final Widget child; + + /// Slot width. Defaults to [ThunderTheme.settingsTileTrailingSlotWidth]. + final double? width; + + /// Slot height. Defaults to [ThunderTheme.settingsTileTrailingSlotHeight]. + final double? height; + + @override + Widget build(BuildContext context) { + final thunderTheme = ThunderTheme.of(context); + + return SizedBox( + width: width ?? thunderTheme.settingsTileTrailingSlotWidth, + height: height ?? thunderTheme.settingsTileTrailingSlotHeight, + child: child, + ); + } +} + +/// Trailing chevron for navigation and list-option settings tiles. +@immutable +class ThunderSettingsChevronTrailing extends StatelessWidget { + const ThunderSettingsChevronTrailing({ + super.key, + this.disabled = false, + }); + + /// When true, the chevron uses the disabled settings tile foreground alpha. + final bool disabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final thunderTheme = ThunderTheme.of(context); + + return ThunderSettingsTrailingSlot( + width: 20.0, + child: Icon( + Icons.chevron_right_rounded, + color: disabled ? theme.colorScheme.onSurface.withValues(alpha: thunderTheme.settingsTileDisabledAlpha) : null, + ), + ); + } +} + +/// Trailing switch slot for toggle settings tiles. +@immutable +class ThunderSettingsSwitchTrailing extends StatelessWidget { + const ThunderSettingsSwitchTrailing({ + super.key, + required this.value, + this.onChanged, + this.placeholder = false, + }); + + /// Current switch value. Ignored when [placeholder] is true. + final bool value; + + /// Called when the switch value changes. + final void Function(bool)? onChanged; + + /// When true, renders a fixed-size empty slot instead of a switch. + final bool placeholder; + + @override + Widget build(BuildContext context) { + if (placeholder) { + return const ThunderSettingsTrailingSlot(child: SizedBox.shrink()); + } + + return ThunderSettingsTrailingSlot( + child: Switch( + value: value, + onChanged: onChanged, + ), + ); + } +} + +/// Trailing expand/collapse arrow for expandable settings sections. +@immutable +class ThunderSettingsExpandTrailing extends StatelessWidget { + const ThunderSettingsExpandTrailing({ + super.key, + required this.expanded, + }); + + /// Whether the section is expanded. + final bool expanded; + + @override + Widget build(BuildContext context) { + return ThunderSettingsTrailingSlot( + child: Icon(expanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded), + ); + } +} diff --git a/lib/packages/ui/src/widgets/settings/thunder_toggle_option.dart b/lib/packages/ui/src/widgets/settings/thunder_toggle_option.dart index f91bd3b9e..0f20a2603 100644 --- a/lib/packages/ui/src/widgets/settings/thunder_toggle_option.dart +++ b/lib/packages/ui/src/widgets/settings/thunder_toggle_option.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:thunder/packages/ui/src/widgets/settings/thunder_settings_tile.dart'; +import 'package:thunder/packages/ui/src/widgets/settings/thunder_settings_trailing.dart'; +/// Settings tile with a trailing switch or placeholder switch slot. +@immutable class ThunderToggleOption extends StatelessWidget { const ThunderToggleOption({ super.key, @@ -17,7 +20,6 @@ class ThunderToggleOption extends StatelessWidget { this.iconDisabled, this.iconEnabledSize, this.iconDisabledSize, - this.iconSpacing = 8, this.additionalTrailing = const [], this.padding, this.highlighted = false, @@ -26,23 +28,55 @@ class ThunderToggleOption extends StatelessWidget { this.disabled = false, }); + /// Primary title text. final String title; + + /// Optional subtitle shown below [title]. final String? subtitle; + + /// Semantic label for accessibility. final String? semanticLabel; + + /// Current switch value. When null, a placeholder slot is shown instead. final bool? value; - final ValueChanged? onChanged; - final VoidCallback? onTap; - final VoidCallback? onLongPress; + + /// Called when the switch value changes. + final void Function(bool)? onChanged; + + /// Called when the tile is tapped. Overrides switch toggling when provided. + final void Function()? onTap; + + /// Called when the tile is long-pressed. + final void Function()? onLongPress; + + /// Leading icon shown when [value] is true. final IconData? iconEnabled; + + /// Leading icon shown when [value] is false. final IconData? iconDisabled; + + /// Size of [iconEnabled]. final double? iconEnabledSize; + + /// Size of [iconDisabled]. final double? iconDisabledSize; - final double iconSpacing; + + /// Extra trailing widgets shown before the switch. final List additionalTrailing; + + /// Outer padding around the tile. final EdgeInsetsGeometry? padding; + + /// Whether to show the smooth highlight animation. final bool highlighted; + + /// Key attached to the highlight widget when [highlighted] is true. final GlobalKey? highlightKey; + + /// Highlight color passed to [ThunderSettingsTile]. final Color? highlightColor; + + /// When true, interaction and switch changes are disabled. final bool disabled; void _handleTap() { @@ -69,9 +103,9 @@ class ThunderToggleOption extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ ...additionalTrailing, - if (additionalTrailing.isNotEmpty) const SizedBox(width: 12), + if (additionalTrailing.isNotEmpty) const SizedBox(width: 12.0), if (value != null) - Switch( + ThunderSettingsSwitchTrailing( value: value!, onChanged: disabled || onChanged == null ? null @@ -81,7 +115,7 @@ class ThunderToggleOption extends StatelessWidget { }, ) else - const SizedBox(height: 50, width: 60), + const ThunderSettingsSwitchTrailing(value: false, placeholder: true), ], ); @@ -89,12 +123,7 @@ class ThunderToggleOption extends StatelessWidget { title: title, subtitle: subtitle, semanticLabel: semanticLabel, - leading: leading == null - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [leading, SizedBox(width: iconSpacing)], - ), + leading: leading, trailing: trailing, padding: padding, highlighted: highlighted, diff --git a/lib/packages/ui/ui.dart b/lib/packages/ui/ui.dart index a468b1f74..62b71e580 100644 --- a/lib/packages/ui/ui.dart +++ b/lib/packages/ui/ui.dart @@ -1,25 +1,59 @@ -export 'src/icons/thunder_icons.dart'; -export 'src/widgets/avatar/models/avatar_data.dart'; -export 'src/widgets/actions/bottom_sheet_action.dart'; +/// Shared Thunder UI widgets, theme extensions, and icons. +/// +/// Import this library in app code: +/// +/// ```dart +/// import 'package:thunder/packages/ui/ui.dart'; +/// ``` +library; + +export 'src/icons/thunder_icon.dart'; +export 'src/theme/thunder_theme.dart'; +export 'src/widgets/avatar/models/thunder_avatar_data.dart'; +export 'src/widgets/avatar/thunder_avatar.dart'; export 'src/widgets/actions/thunder_action_chip.dart'; +export 'src/widgets/actions/thunder_bottom_sheet_action.dart'; export 'src/widgets/actions/thunder_multi_action_dismissible.dart'; export 'src/widgets/actions/thunder_popup_menu_item.dart'; -export 'src/widgets/common/thunder_error_state.dart'; +export 'src/widgets/actions/thunder_preview_action_row.dart'; +export 'src/widgets/actions/thunder_swipe_action_background.dart'; +export 'src/widgets/common/thunder_empty_text.dart'; export 'src/widgets/common/thunder_icon_label.dart'; +export 'src/widgets/common/thunder_skeleton_bar.dart'; +export 'src/widgets/common/thunder_skeleton_placeholder.dart'; +export 'src/widgets/common/thunder_state_action.dart'; +export 'src/widgets/common/thunder_state_actions.dart'; +export 'src/widgets/common/thunder_state_icon.dart'; +export 'src/widgets/common/thunder_state_text.dart'; +export 'src/widgets/common/thunder_state_view.dart'; export 'src/widgets/dialogs/thunder_dialog.dart'; export 'src/widgets/dialogs/thunder_typeahead_dialog.dart'; export 'src/widgets/fab/thunder_expandable_fab.dart'; -export 'src/widgets/feedback/snackbar.dart'; -export 'src/widgets/avatar/avatar.dart'; -export 'src/widgets/identity/scalable_text.dart'; -export 'src/widgets/layout/conditional_parent_widget.dart'; +export 'src/widgets/feedback/thunder_snackbar.dart'; +export 'src/widgets/identity/thunder_scalable_text.dart'; export 'src/widgets/layout/thunder_bottom_sheet.dart'; +export 'src/widgets/layout/thunder_composer_bar.dart'; +export 'src/widgets/layout/thunder_conditional_parent.dart'; export 'src/widgets/layout/thunder_divider.dart'; +export 'src/widgets/layout/thunder_marquee.dart'; +export 'src/widgets/layout/thunder_metadata_row.dart'; +export 'src/widgets/layout/thunder_section_divider.dart'; +export 'src/widgets/layout/thunder_section_header.dart'; +export 'src/widgets/layout/thunder_section_title.dart'; +export 'src/widgets/layout/thunder_selectable_tile_shell.dart'; +export 'src/widgets/layout/thunder_sidebar_stat.dart'; +export 'src/widgets/layout/thunder_split_action_row.dart'; +export 'src/widgets/layout/thunder_sliver_adapter.dart'; +export 'src/widgets/layout/thunder_top_bar_scrim.dart'; export 'src/widgets/media/thunder_image_viewer.dart'; -export 'src/widgets/pickers/bottom_sheet_list_picker.dart'; -export 'src/widgets/pickers/multi_picker_item.dart'; -export 'src/widgets/pickers/picker_item.dart'; +export 'src/widgets/media/thunder_media_preview_error.dart'; +export 'src/widgets/navigation/thunder_bottom_navigation_bar.dart'; +export 'src/widgets/pickers/show_thunder_list_picker.dart'; +export 'src/widgets/pickers/thunder_bottom_sheet_list_picker.dart'; +export 'src/widgets/pickers/thunder_multi_picker_item.dart'; +export 'src/widgets/pickers/thunder_picker_item.dart'; export 'src/widgets/settings/thunder_expandable_option.dart'; export 'src/widgets/settings/thunder_list_option.dart'; export 'src/widgets/settings/thunder_settings_tile.dart'; +export 'src/widgets/settings/thunder_settings_trailing.dart'; export 'src/widgets/settings/thunder_toggle_option.dart'; diff --git a/lib/src/app/intent/share_intent_handler.dart b/lib/src/app/intent/share_intent_handler.dart index 6914e777a..fdbaad3e0 100644 --- a/lib/src/app/intent/share_intent_handler.dart +++ b/lib/src/app/intent/share_intent_handler.dart @@ -8,7 +8,7 @@ import 'package:flutter_sharing_intent/model/sharing_file.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Handles share intents (external content shared to Thunder) from the OS. /// @@ -46,7 +46,7 @@ class ShareIntentHandler { }); } catch (e) { if (context.mounted) { - showSnackbar(l10n.unexpectedError); + showThunderSnackbar(l10n.unexpectedError); } } } diff --git a/lib/src/app/shell/navigation/navigation_instance.dart b/lib/src/app/shell/navigation/navigation_instance.dart index 27be6f4f1..1f671944f 100644 --- a/lib/src/app/shell/navigation/navigation_instance.dart +++ b/lib/src/app/shell/navigation/navigation_instance.dart @@ -68,7 +68,7 @@ Future navigateToInstancePage( } else { final l10n = GlobalContext.l10n; - showSnackbar( + showThunderSnackbar( l10n.unableToNavigateToInstance(instanceHost), trailingAction: () => handleLink(context, url: "https://$instanceHost", forceOpenInBrowser: true), trailingIcon: Icons.open_in_browser_rounded, diff --git a/lib/src/app/shell/navigation/navigation_post.dart b/lib/src/app/shell/navigation/navigation_post.dart index 9dffadedc..3b018af71 100644 --- a/lib/src/app/shell/navigation/navigation_post.dart +++ b/lib/src/app/shell/navigation/navigation_post.dart @@ -283,7 +283,7 @@ Future navigateToCreatePostPage( // Show snackbar message if the post was just created if (!userChanged && post == null) { try { - showSnackbar( + showThunderSnackbar( l10n.postCreatedSuccessfully, trailingIcon: Icons.remove_red_eye_rounded, trailingAction: () { @@ -292,7 +292,7 @@ Future navigateToCreatePostPage( ); } catch (e) { if (context.mounted) { - showSnackbar("${AppLocalizations.of(context)!.unexpectedError}: $e"); + showThunderSnackbar("${AppLocalizations.of(context)!.unexpectedError}: $e"); } } } @@ -307,7 +307,7 @@ Future navigateToCreatePostPage( )); } catch (e) { if (context.mounted) { - showSnackbar(AppLocalizations.of(context)!.unexpectedError); + showThunderSnackbar(AppLocalizations.of(context)!.unexpectedError); } } } diff --git a/lib/src/app/shell/navigation/navigation_private_message.dart b/lib/src/app/shell/navigation/navigation_private_message.dart index 0566ade8f..ce10501f7 100644 --- a/lib/src/app/shell/navigation/navigation_private_message.dart +++ b/lib/src/app/shell/navigation/navigation_private_message.dart @@ -63,7 +63,7 @@ Future navigateToCreatePrivateMessagePage( final result = await pushOnTopOfLoadingPage(context, route); if (result is ThunderPrivateMessage) return result; } catch (e) { - showSnackbar(e.toString()); + showThunderSnackbar(e.toString()); } return null; diff --git a/lib/src/app/shell/navigation/navigation_utils.dart b/lib/src/app/shell/navigation/navigation_utils.dart index a4990c064..a7c75fee4 100644 --- a/lib/src/app/shell/navigation/navigation_utils.dart +++ b/lib/src/app/shell/navigation/navigation_utils.dart @@ -38,8 +38,8 @@ import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/utils/cache/platform_version_cache.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; import 'package:thunder/src/features/post/presentation/state/post_bloc.dart' as post_bloc; +import 'package:thunder/packages/ui/ui.dart'; part 'navigation_feed.dart'; part 'navigation_instance.dart'; diff --git a/lib/src/app/shell/pages/thunder_page.dart b/lib/src/app/shell/pages/thunder_page.dart index cfe6c1050..9118dd25c 100644 --- a/lib/src/app/shell/pages/thunder_page.dart +++ b/lib/src/app/shell/pages/thunder_page.dart @@ -38,10 +38,9 @@ import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/error_message.dart'; import 'package:thunder/src/app/state/app_bootstrap_cubit/app_bootstrap_cubit.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; String? currentIntent; bool hasAttemptedDraftRestore = false; @@ -121,7 +120,7 @@ class _ThunderState extends State { } void _showExitWarning() { - showSnackbar( + showThunderSnackbar( AppLocalizations.of(context)!.tapToExit, duration: const Duration(milliseconds: 3500), closable: false, @@ -292,9 +291,9 @@ class _ThunderState extends State { case DeepLinkStatus.loading: return; case DeepLinkStatus.empty: - showSnackbar(state.error ?? l10n.emptyUri); + showThunderSnackbar(state.error ?? l10n.emptyUri); case DeepLinkStatus.error: - showSnackbar(state.error ?? l10n.exceptionProcessingUri); + showThunderSnackbar(state.error ?? l10n.exceptionProcessingUri); case DeepLinkStatus.success: try { @@ -304,7 +303,7 @@ class _ThunderState extends State { } case DeepLinkStatus.unknown: - showSnackbar(state.error ?? l10n.uriNotSupported); + showThunderSnackbar(state.error ?? l10n.uriNotSupported); } }, ), @@ -581,26 +580,26 @@ class _ThunderState extends State { case ProfileStatus.loading: return Container(); case ProfileStatus.failureCheckingInstance: - showSnackbar(state.error ?? AppLocalizations.of(context)!.missingErrorMessage); + showThunderSnackbar(state.error ?? AppLocalizations.of(context)!.missingErrorMessage); errorMessageLoading = false; return StatefulBuilder( - builder: (context, setState) => ErrorMessage( + builder: (context, setState) => ThunderStateView( title: AppLocalizations.of(context)!.unableToLoadInstance(state.account.instance), message: AppLocalizations.of(context)!.internetOrInstanceIssues, actions: [ - ( - text: AppLocalizations.of(context)!.retry, - action: () { + ThunderStateAction( + label: AppLocalizations.of(context)!.retry, + onPressed: () { context.read().add(InitializeAuth()); setState(() => errorMessageLoading = true); }, loading: errorMessageLoading, + primary: true, ), - ( - text: AppLocalizations.of(context)!.accountSettings, - action: () => showProfileModalSheet(context), - loading: false, + ThunderStateAction( + label: AppLocalizations.of(context)!.accountSettings, + onPressed: () => showProfileModalSheet(context), ), ], ), @@ -610,13 +609,14 @@ class _ThunderState extends State { ), ); case AppBootstrapStatus.failure: - return ErrorMessage( + return ThunderStateView( + title: AppLocalizations.of(context)!.somethingWentWrong, message: appBootstrapState.errorMessage, actions: [ - ( - text: AppLocalizations.of(context)!.refreshContent, - action: () => context.read().initialize(), - loading: false, + ThunderStateAction( + label: AppLocalizations.of(context)!.refreshContent, + onPressed: () => context.read().initialize(), + primary: true, ), ], ); diff --git a/lib/src/app/shell/routing/deep_link.dart b/lib/src/app/shell/routing/deep_link.dart index 31843de6d..a733c4e7c 100644 --- a/lib/src/app/shell/routing/deep_link.dart +++ b/lib/src/app/shell/routing/deep_link.dart @@ -11,7 +11,7 @@ import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Custom exception for deep link related errors class DeepLinkException implements Exception { @@ -191,11 +191,11 @@ Future _handleNavigation(BuildContext context, LinkType linkType /// If a fallback URL is provided, shows an additional action to open the link in an external browser. void showNavigationError(BuildContext context, String error, String? fallbackUrl) { if (fallbackUrl == null) { - showSnackbar(error); + showThunderSnackbar(error); return; } - showSnackbar( + showThunderSnackbar( error, trailingIcon: Icons.open_in_browser_rounded, duration: const Duration(seconds: 10), diff --git a/lib/src/app/shell/thunder_app.dart b/lib/src/app/shell/thunder_app.dart index 118296f44..05f0f2507 100644 --- a/lib/src/app/shell/thunder_app.dart +++ b/lib/src/app/shell/thunder_app.dart @@ -16,6 +16,7 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/app/shell/widgets/session.dart'; import 'package:thunder/src/app/shell/widgets/session_scope.dart'; import 'package:thunder/src/features/settings/api.dart'; @@ -124,6 +125,7 @@ class _ThunderAppState extends State { theme = theme.copyWith( pageTransitionsTheme: pageTransitionsTheme, + extensions: const [ThunderTheme()], inputDecorationTheme: InputDecorationTheme( hintStyle: TextStyle( color: lightColorScheme?.onSurface.withValues(alpha: 0.6), @@ -132,6 +134,7 @@ class _ThunderAppState extends State { ); darkTheme = darkTheme.copyWith( pageTransitionsTheme: pageTransitionsTheme, + extensions: const [ThunderTheme()], inputDecorationTheme: InputDecorationTheme( hintStyle: TextStyle( color: darkColorScheme?.onSurface.withValues(alpha: 0.6), diff --git a/lib/src/app/shell/widgets/bottom_nav_bar.dart b/lib/src/app/shell/widgets/bottom_nav_bar.dart index 42ce7d675..e6a7b798c 100644 --- a/lib/src/app/shell/widgets/bottom_nav_bar.dart +++ b/lib/src/app/shell/widgets/bottom_nav_bar.dart @@ -10,7 +10,7 @@ import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/src/app/shell/widgets/thunder_bottom_nav_bar.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; /// Defines the bottom navigation bar for Thunder. Uses a custom [ThunderBottomNavigationBar] to handle additional gestures and long-press behavior. diff --git a/lib/src/app/wiring/state_factories.dart b/lib/src/app/wiring/state_factories.dart index c1e35b9ac..00ab98153 100644 --- a/lib/src/app/wiring/state_factories.dart +++ b/lib/src/app/wiring/state_factories.dart @@ -121,9 +121,9 @@ InstancePageBloc createInstancePageBloc({ required Account account, required ThunderInstanceInfo instanceInfo, }) { - final uri = Uri.parse(instanceInfo.domain); + final instanceAuthority = normalizeInstanceHost(instanceInfo.domain) ?? instanceInfo.domain; final remoteAccount = Account( - instance: uri.host, + instance: instanceAuthority, id: '', index: -1, platform: instanceInfo.platform, diff --git a/lib/src/features/account/presentation/pages/login_page.dart b/lib/src/features/account/presentation/pages/login_page.dart index 0b774f02c..0c65cec41 100644 --- a/lib/src/features/account/presentation/pages/login_page.dart +++ b/lib/src/features/account/presentation/pages/login_page.dart @@ -5,7 +5,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/account/account.dart'; @@ -14,6 +13,7 @@ import 'package:thunder/src/features/account/presentation/widgets/login/login_pa import 'package:thunder/src/features/instance/domain/models/instance_discovery_result.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// Coordinates instance validation and session submission for the login flow. class LoginPage extends StatefulWidget { @@ -117,14 +117,14 @@ class _LoginPageState extends State { _isSubmitting.value = true; case SessionMutationStatus.failure: _isSubmitting.value = false; - showSnackbar(GlobalContext.l10n.loginFailed(state.error ?? GlobalContext.l10n.missingErrorMessage)); + showThunderSnackbar(GlobalContext.l10n.loginFailed(state.error ?? GlobalContext.l10n.missingErrorMessage)); case SessionMutationStatus.success: _isSubmitting.value = false; if (widget.anonymous) { widget.popRegister(); } else { widget.popModal(); - showSnackbar(GlobalContext.l10n.loginSucceeded); + showThunderSnackbar(GlobalContext.l10n.loginSucceeded); } case SessionMutationStatus.idle: break; @@ -136,7 +136,7 @@ class _LoginPageState extends State { if (_isSubmitting.value) return; if (!_instanceValidationCubit.state.isValid) { - showSnackbar(l10n.notValidLemmyInstance(_instanceController.text)); + showThunderSnackbar(l10n.notValidLemmyInstance(_instanceController.text)); return; } @@ -150,7 +150,7 @@ class _LoginPageState extends State { final platform = validationState.platform; if (!validationState.isValid || instanceInfo == null || instanceHost == null || platform == null) { _isSubmitting.value = false; - showSnackbar(l10n.notValidLemmyInstance(_instanceController.text)); + showThunderSnackbar(l10n.notValidLemmyInstance(_instanceController.text)); return; } diff --git a/lib/src/features/account/presentation/widgets/account_placeholder.dart b/lib/src/features/account/presentation/widgets/account_placeholder.dart index b2fbfae82..12828c9b7 100644 --- a/lib/src/features/account/presentation/widgets/account_placeholder.dart +++ b/lib/src/features/account/presentation/widgets/account_placeholder.dart @@ -1,43 +1,53 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/foundation/config/global_context.dart'; - -/// A widget that displays a placeholder when no user account is logged in. -/// -/// The widget is used in the Account page to prompt the user to login to an account. -class AccountPlaceholder extends StatelessWidget { - const AccountPlaceholder({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - final account = context.select((bloc) => bloc.state.account); - - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.people_rounded, size: 100.0, color: theme.dividerColor), - const SizedBox(height: 16.0), - Text(l10n.browsingAnonymously(account.instance), textAlign: TextAlign.center), - Text(l10n.addAccountToSeeProfile, textAlign: TextAlign.center), - const SizedBox(height: 24.0), - ElevatedButton( - style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(60)), - child: Text(l10n.manageAccounts), - onPressed: () => showProfileModalSheet(context), - ) - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; + +/// A widget that displays a placeholder when no user account is logged in. +/// +/// The widget is used in the Account page to prompt the user to login to an account. +class AccountPlaceholder extends StatelessWidget { + const AccountPlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + final account = context.select((bloc) => bloc.state.account); + final bodyStyle = theme.textTheme.bodyMedium; + + return ThunderStateView( + mode: ThunderStateViewMode.custom, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ThunderStateIcon( + icon: Icons.people_rounded, + color: theme.dividerColor, + ), + const SizedBox(height: 16), + ThunderStateText( + title: l10n.browsingAnonymously(account.instance), + message: l10n.addAccountToSeeProfile, + titleStyle: bodyStyle, + messageStyle: bodyStyle, + ), + const SizedBox(height: 24), + ThunderStateActions( + actions: [ + ThunderStateAction( + label: l10n.manageAccounts, + onPressed: () => showProfileModalSheet(context), + primary: true, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_anonymous_instance_tile.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_anonymous_instance_tile.dart index 3dc459ec3..e591cef69 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_anonymous_instance_tile.dart +++ b/lib/src/features/account/presentation/widgets/profile_modal/profile_anonymous_instance_tile.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/account/presentation/state/profile_modal_cubit.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_instance_status_avatar.dart'; -import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_metadata.dart'; -import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_tile_shell.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_trailing_action.dart'; /// Displays one anonymous instance in the profile modal. @@ -58,8 +57,8 @@ class ProfileAnonymousInstanceTile extends StatelessWidget { final theme = Theme.of(context); final l10n = GlobalContext.l10n; - return ProfileTileShell( - active: active, + return ThunderSelectableTileShell( + selected: active, reordering: reordering, selectedColor: selectedColor, onTap: onTap, @@ -84,9 +83,9 @@ class ProfileAnonymousInstanceTile extends StatelessWidget { ), ], ), - subtitle: ProfileMetadata( - instance: row.account.instance, - version: row.version, + subtitle: ThunderMetadataRow( + primary: row.account.instance, + secondary: row.version == null ? null : 'v${row.version}', ), trailing: ProfileTrailingAction( active: active, diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_authenticated_account_tile.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_authenticated_account_tile.dart index 28d99da9d..750aa482c 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_authenticated_account_tile.dart +++ b/lib/src/features/account/presentation/widgets/profile_modal/profile_authenticated_account_tile.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/account/presentation/state/profile_modal_cubit.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_instance_status_avatar.dart'; -import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_metadata.dart'; -import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_tile_shell.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_trailing_action.dart'; /// Displays one authenticated account in the profile modal. @@ -62,8 +61,8 @@ class ProfileAuthenticatedAccountTile extends StatelessWidget { final theme = Theme.of(context); final l10n = GlobalContext.l10n; - return ProfileTileShell( - active: active, + return ThunderSelectableTileShell( + selected: active, reordering: reordering, selectedColor: selectedColor, onTap: onTap, @@ -94,9 +93,9 @@ class ProfileAuthenticatedAccountTile extends StatelessWidget { ), ], ), - subtitle: ProfileMetadata( - instance: row.account.instance.replaceAll('https://', ''), - version: row.version, + subtitle: ThunderMetadataRow( + primary: row.account.instance.replaceAll('https://', ''), + secondary: row.version == null ? null : 'v${row.version}', ), trailing: ProfileTrailingAction( active: active, diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_empty_message.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_empty_message.dart deleted file mode 100644 index 39759669d..000000000 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_empty_message.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Displays an italic empty-state message within a sliver list. -class ProfileEmptyMessage extends StatelessWidget { - const ProfileEmptyMessage({ - super.key, - required this.message, - }); - - /// Localized message describing the empty section. - final String message; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 24.0, bottom: 16.0), - child: Text( - message, - style: theme.textTheme.bodyMedium?.copyWith( - fontStyle: FontStyle.italic, - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5), - ), - ), - ), - ); - } -} diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_modal_load_state.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_modal_load_state.dart index 39ef8b965..ffe459edd 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_modal_load_state.dart +++ b/lib/src/features/account/presentation/widgets/profile_modal/profile_modal_load_state.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; /// Displays the profile modal's initial loading or recoverable failure state. @@ -20,29 +21,28 @@ class ProfileModalLoadState extends StatelessWidget { Widget build(BuildContext context) { final l10n = GlobalContext.l10n; - return SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: failed - ? Column( - key: const Key('profile-load-failure'), - mainAxisSize: MainAxisSize.min, - spacing: 16.0, - children: [ - const Icon(Icons.error_outline_rounded, size: 40.0), - Text(l10n.somethingWentWrong, textAlign: TextAlign.center), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded), - label: Text(l10n.retry), - ), - ], - ) - : Semantics( - label: l10n.loading, - child: const CircularProgressIndicator(key: Key('profile-load-progress')), - ), - ), + if (failed) { + return ThunderStateView( + key: const Key('profile-load-failure'), + sliver: true, + fillRemaining: true, + compact: true, + icon: Icons.error_outline_rounded, + title: l10n.somethingWentWrong, + actions: [ + ThunderStateAction( + label: l10n.retry, + onPressed: onRetry!, + primary: true, + ), + ], + ); + } + + return ThunderStateView.loading( + key: const Key('profile-load-progress'), + sliver: true, + semanticsLabel: l10n.loading, ); } } diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_section_header.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_section_header.dart deleted file mode 100644 index f3351fdd1..000000000 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_section_header.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Displays a transparent sliver header for a profile-modal section. -class ProfileSectionHeader extends StatelessWidget { - const ProfileSectionHeader({ - super.key, - required this.title, - required this.actions, - }); - - /// Text displayed as the section heading. - final String title; - - /// Actions displayed at the trailing edge of the header. - final List actions; - - @override - Widget build(BuildContext context) { - return SliverAppBar( - title: Text(title), - centerTitle: false, - scrolledUnderElevation: 0, - pinned: false, - forceMaterialTransparency: true, - actions: actions, - ); - } -} diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_select.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_select.dart index 24c4644d0..0f11fa8b9 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_select.dart +++ b/lib/src/features/account/presentation/widgets/profile_modal/profile_select.dart @@ -4,18 +4,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/account/presentation/state/profile_modal_cubit.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_anonymous_instances_sliver.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_authenticated_accounts_sliver.dart'; -import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_empty_message.dart'; import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_modal_load_state.dart'; -import 'package:thunder/src/features/account/presentation/widgets/profile_modal/profile_section_header.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/presentation/utils/user_session_utils.dart'; import 'package:thunder/src/foundation/contracts/account.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// Displays profile sections and coordinates profile-modal user actions. class ProfileSelect extends StatefulWidget { @@ -76,7 +74,7 @@ class _ProfileSelectState extends State { final modalCubit = context.read(); if (state.mutationStatus == SessionMutationStatus.failure) { modalCubit.clearPendingSession(); - showSnackbar(l10n.somethingWentWrong); + showThunderSnackbar(l10n.somethingWentWrong); return; } @@ -87,11 +85,11 @@ class _ProfileSelectState extends State { listenWhen: (previous, current) => previous.operationError != current.operationError || (previous.loadError != current.loadError && current.status == ProfileModalStatus.success), listener: (context, state) { if (state.operationError != null) { - showSnackbar(l10n.somethingWentWrong); + showThunderSnackbar(l10n.somethingWentWrong); context.read().clearOperationError(); } if (state.loadError != null && state.status == ProfileModalStatus.success) { - showSnackbar(l10n.somethingWentWrong); + showThunderSnackbar(l10n.somethingWentWrong); context.read().clearLoadError(); } }, @@ -107,9 +105,9 @@ class _ProfileSelectState extends State { return CustomScrollView( slivers: [ if (scaffoldState.status != ProfileModalStatus.success) - ProfileSectionHeader( + ThunderSectionHeader( title: widget.customHeading ?? l10n.account(2), - actions: const [], + variant: ThunderSectionHeaderVariant.sliver, ), if (scaffoldState.status == ProfileModalStatus.loading || scaffoldState.status == ProfileModalStatus.initial) ...[ const ProfileModalLoadState.loading(), @@ -214,8 +212,9 @@ class _AuthenticatedProfileSection extends StatelessWidget { builder: (context, state) { return SliverMainAxisGroup( slivers: [ - ProfileSectionHeader( + ThunderSectionHeader( title: customHeading ?? l10n.account(2), + variant: ThunderSectionHeaderVariant.sliver, actions: quickSelectMode ? const [] : [ @@ -248,7 +247,10 @@ class _AuthenticatedProfileSection extends StatelessWidget { onRemove: onRemove, ) else - ProfileEmptyMessage(message: l10n.noAccountsAdded), + ThunderSliverAdapter( + sliver: true, + child: ThunderEmptyText(message: l10n.noAccountsAdded), + ) ], ); }, @@ -282,8 +284,9 @@ class _AnonymousProfileSection extends StatelessWidget { builder: (context, state) { return SliverMainAxisGroup( slivers: [ - ProfileSectionHeader( + ThunderSectionHeader( title: l10n.anonymousInstances, + variant: ThunderSectionHeaderVariant.sliver, actions: [ if (state.rows.length > 1) IconButton( @@ -313,7 +316,10 @@ class _AnonymousProfileSection extends StatelessWidget { onRemove: onRemove, ) else - ProfileEmptyMessage(message: l10n.noAnonymousInstances), + ThunderSliverAdapter( + sliver: true, + child: ThunderEmptyText(message: l10n.noAnonymousInstances), + ) ], ); }, diff --git a/lib/src/features/account/presentation/widgets/profile_modal/profile_tile_shell.dart b/lib/src/features/account/presentation/widgets/profile_modal/profile_tile_shell.dart deleted file mode 100644 index 1ab26a6df..000000000 --- a/lib/src/features/account/presentation/widgets/profile_modal/profile_tile_shell.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Provides the shared selection, elevation, and tap styling for profile rows. -class ProfileTileShell extends StatelessWidget { - const ProfileTileShell({ - super.key, - required this.active, - required this.reordering, - required this.selectedColor, - required this.onTap, - required this.child, - }); - - /// Whether the row represents the active session. - final bool active; - - /// Whether the row is currently being dragged during reordering. - final bool reordering; - - /// Background color used to identify the active session. - final Color selectedColor; - - /// Callback invoked when the row is selected, or `null` to disable taps. - final VoidCallback? onTap; - - /// Row content displayed inside the interactive surface. - final Widget child; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(10.0, 0.0, 10.0, 0.0), - child: Material( - elevation: reordering ? 3.0 : 0.0, - color: active ? selectedColor : Colors.transparent, - borderRadius: BorderRadius.circular(50.0), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(50.0), - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - child: child, - ), - ), - ), - ); - } -} diff --git a/lib/src/features/comment/presentation/pages/create_comment_page.dart b/lib/src/features/comment/presentation/pages/create_comment_page.dart index 45d93f645..5a450805c 100644 --- a/lib/src/features/comment/presentation/pages/create_comment_page.dart +++ b/lib/src/features/comment/presentation/pages/create_comment_page.dart @@ -27,8 +27,8 @@ import 'package:thunder/src/shared/theme/color_utils.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; import 'package:thunder/src/shared/media/media_utils.dart' show selectImagesToUpload; +import 'package:thunder/packages/ui/ui.dart'; class CreateCommentPage extends StatefulWidget { /// The account to use for composing this comment. @@ -192,7 +192,7 @@ class _CreateCommentPageState extends State with WidgetsBindi Future.delayed(const Duration(milliseconds: 1000), () { if (!mounted) return; - showSnackbar( + showThunderSnackbar( AppLocalizations.of(context)!.restoredCommentFromDraft, trailingIcon: Icons.delete_forever_rounded, trailingIconColor: Theme.of(context).colorScheme.errorContainer, @@ -241,7 +241,7 @@ class _CreateCommentPageState extends State with WidgetsBindi ); if (showSaveDraftSnackbar && result == DraftPersistenceResult.saved) { - showSnackbar(GlobalContext.l10n.commentSavedAsDraft); + showThunderSnackbar(GlobalContext.l10n.commentSavedAsDraft); } return result; @@ -267,17 +267,24 @@ class _CreateCommentPageState extends State with WidgetsBindi } if (state.status == CreateCommentStatus.error && state.message != null) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); ctx.read().clearMessage(); } switch (state.status) { case CreateCommentStatus.imageUploadSuccess: - String markdownImages = state.imageUrls?.map((url) => '![]($url)').join('\n\n') ?? ''; - _bodyTextController.text = _bodyTextController.text.replaceRange(_bodyTextController.selection.end, _bodyTextController.selection.end, markdownImages); + final markdownImages = state.imageUrls?.map((url) => '![]($url)').join('\n\n') ?? ''; + if (markdownImages.isEmpty) { + ctx.read().clearMessage(); + break; + } + + final insertIndex = _bodyTextController.selection.isValid ? _bodyTextController.selection.end : _bodyTextController.text.length; + _bodyTextController.text = _bodyTextController.text.replaceRange(insertIndex, insertIndex, markdownImages); + ctx.read().clearMessage(); break; case CreateCommentStatus.imageUploadFailure: - showSnackbar(l10n.postUploadImageError, leadingIcon: Icons.warning_rounded, leadingIconColor: theme.colorScheme.errorContainer); + showThunderSnackbar(l10n.postUploadImageError, leadingIcon: Icons.warning_rounded, leadingIconColor: theme.colorScheme.errorContainer); default: break; } diff --git a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart index cd6c6068b..f15d33c5a 100644 --- a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart +++ b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/shared/text/selectable_text_modal.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the actions that can be taken on a comment enum CommentBottomSheetAction { @@ -145,13 +145,13 @@ class _CommentCommentActionBottomSheetState extends State( - (commentBottomSheetAction) => BottomSheetAction( + (commentBottomSheetAction) => ThunderBottomSheetAction( leading: Icon(commentBottomSheetAction.icon), title: commentBottomSheetAction.name, onTap: () => performAction(commentBottomSheetAction), @@ -243,12 +243,12 @@ class _CommentCommentActionBottomSheetState extends State( - (commentBottomSheetAction) => BottomSheetAction( + (commentBottomSheetAction) => ThunderBottomSheetAction( leading: Icon(commentBottomSheetAction.icon), trailing: Padding( padding: const EdgeInsets.only(left: 1), child: Icon( - Thunder.shield, + ThunderIcon.shield, size: 20, color: Color.alphaBlend(theme.colorScheme.primary.withValues(alpha: 0.4), Colors.green), ), diff --git a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart index 5c11df6ae..52a4c580b 100644 --- a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart +++ b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/shared/name/full_name_copy_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, MultiPickerItem, PickerItemData; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the general actions that can be taken on a comment enum GeneralCommentAction { @@ -264,8 +264,8 @@ class _GeneralCommentActionBottomSheetPageState extends State((quickCommentAction) { + ThunderMultiPickerItem( + pickerItems: quickActions.map((quickCommentAction) { Function()? onSelected; if (quickCommentAction == GeneralQuickCommentAction.downvote && !widget.downvotesEnabled) { @@ -274,7 +274,7 @@ class _GeneralCommentActionBottomSheetPageState extends State performAction(quickCommentAction); } - return PickerItemData( + return ThunderMultiPickerItemData( icon: getIcon(quickCommentAction), label: getLabel(quickCommentAction), foregroundColor: getForegroundColor(quickCommentAction), @@ -284,7 +284,7 @@ class _GeneralCommentActionBottomSheetPageState extends State( - (page) => BottomSheetAction( + (page) => ThunderBottomSheetAction( leading: Icon(page.icon), trailing: const Icon(Icons.chevron_right_rounded), title: page.name, diff --git a/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart b/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart index dfc195c99..ad1bccaa2 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart @@ -7,7 +7,7 @@ import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; +import 'package:thunder/packages/ui/ui.dart'; class AdditionalCommentCard extends StatefulWidget { /// The function to call when tapped @@ -63,7 +63,7 @@ class _AdditionalCommentCardState extends State { children: [ Container( padding: const EdgeInsets.fromLTRB(12.0, 12.0, 0.0, 12.0), - child: ScalableText( + child: ThunderScalableText( reply, textScaleFactor: commentFontSizeScale.textScaleFactor, style: theme.textTheme.bodyMedium?.copyWith( diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart index d9682929e..e74624d08 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/features/settings/api.dart'; /// A widget that displays the proper background when a swipe action is performed on a comment. @@ -36,14 +37,11 @@ class CommentCardBackground extends StatelessWidget { final backgroundColor = swipeAction != null ? swipeAction!.getColor(context) : defaultColor.withValues(alpha: dismissThreshold / firstActionThreshold); - return AnimatedContainer( + return ThunderSwipeActionBackground( alignment: alignment, - duration: const Duration(milliseconds: 200), - color: backgroundColor, - child: SizedBox( - width: MediaQuery.sizeOf(context).width * dismissThreshold, - child: swipeAction != null ? Icon(swipeAction!.getIcon()) : const SizedBox.shrink(), - ), + backgroundColor: backgroundColor, + width: MediaQuery.sizeOf(context).width * dismissThreshold, + icon: swipeAction?.getIcon(), ); } } diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart index 1e7740cd9..74bc8a3c4 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the timestamp for a comment, with special styling for recent comments. /// @@ -32,7 +32,7 @@ class CommentCardHeaderDate extends StatelessWidget { final theme = Theme.of(context); final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); - final formattedDate = ScalableText( + final formattedDate = ThunderScalableText( date, textScaleFactor: metadataFontSizeScale.textScaleFactor, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart index 945e8b8fa..9645143be 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/comment/api.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the number of replies to a comment. /// @@ -35,7 +35,7 @@ class CommentCardHeaderReplyCount extends StatelessWidget { color: theme.colorScheme.primaryContainer, borderRadius: const BorderRadius.all(Radius.elliptical(5.0, 5.0)), ), - child: ScalableText('+$replies', textScaleFactor: metadataFontSizeScale.textScaleFactor), + child: ThunderScalableText('+$replies', textScaleFactor: metadataFontSizeScale.textScaleFactor), ), ); } diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart index 271467422..235fe1c42 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays voting scores for comments with upvote/downvote indicators /// @@ -99,7 +99,7 @@ class _CommentCardHeaderScoreState extends State { spacing: 2.0, children: [ VoteIcon(type: 1, voteType: widget.voteType, color: upvoteColor, fontScale: metadataFontSizeScale), - ScalableText( + ThunderScalableText( scoreLabel, semanticsLabel: l10n.xScore(scoreLabel), textScaleFactor: metadataFontSizeScale.textScaleFactor, @@ -122,7 +122,7 @@ class _CommentCardHeaderScoreState extends State { children: [ VoteIcon(type: 1, voteType: widget.voteType, color: upvoteColor, fontScale: metadataFontSizeScale), const SizedBox(width: 2.0), - ScalableText( + ThunderScalableText( upvotesLabel, semanticsLabel: l10n.xUpvotes(upvotesLabel), textScaleFactor: metadataFontSizeScale.textScaleFactor, @@ -134,7 +134,7 @@ class _CommentCardHeaderScoreState extends State { if (widget.downvotes != 0) ...[ VoteIcon(type: -1, voteType: widget.voteType, color: downvoteColor, fontScale: metadataFontSizeScale), const SizedBox(width: 2.0), - ScalableText( + ThunderScalableText( downvotesLabel, semanticsLabel: l10n.xDownvotes(downvotesLabel), textScaleFactor: metadataFontSizeScale.textScaleFactor, diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart index 0b27c172d..77c9b5bb5 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart @@ -9,11 +9,10 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/reply_to_preview_actions.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; +import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/comment/api.dart'; import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/packages/ui/ui.dart' show ConditionalParentWidget; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the content of a comment. class CommentContent extends StatefulWidget { @@ -123,7 +122,7 @@ class _CommentContentState extends State with SingleTickerProvid children: [ Padding( padding: EdgeInsets.only(top: 0.0, right: 8.0, left: 8.0, bottom: 8.0), - child: ConditionalParentWidget( + child: ThunderConditionalParent( condition: widget.selectable, parentBuilder: (child) { return SelectableRegion( @@ -141,7 +140,7 @@ class _CommentContentState extends State with SingleTickerProvid ); }, child: widget.viewSource - ? ScalableText( + ? ThunderScalableText( content, style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), textScaleFactor: contentFontSizeScale.textScaleFactor, @@ -153,10 +152,14 @@ class _CommentContentState extends State with SingleTickerProvid ), ), if (widget.showReplyEditorButtons && widget.comment.content.isNotEmpty == true) - ReplyToPreviewActions( + ThunderPreviewActionRow( viewSource: widget.viewSource, onViewSourceToggled: widget.onViewSourceToggled, text: content, + viewSourceLabel: GlobalContext.l10n.viewSource, + viewOriginalLabel: GlobalContext.l10n.viewOriginal, + copyLabel: GlobalContext.l10n.copyText, + copiedMessage: GlobalContext.l10n.copiedToClipboard, ), ], ), diff --git a/lib/src/features/comment/presentation/widgets/comment_reference.dart b/lib/src/features/comment/presentation/widgets/comment_reference.dart index 7c979fc67..ac64f85df 100644 --- a/lib/src/features/comment/presentation/widgets/comment_reference.dart +++ b/lib/src/features/comment/presentation/widgets/comment_reference.dart @@ -9,12 +9,11 @@ import 'package:thunder/src/features/comment/api.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/shared/name/full_name_widgets.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays a reference to a comment with additional post and community information. /// @@ -37,7 +36,7 @@ class CommentReference extends StatelessWidget { final l10n = GlobalContext.l10n; if (comment.post?.status.deleted == true && comment.post?.creatorId != account.userId) { - return showSnackbar(l10n.unableToLoadPost); + return showThunderSnackbar(l10n.unableToLoadPost); } navigateToComment(context, comment); @@ -130,7 +129,7 @@ class _CommentReferenceHeader extends StatelessWidget { spacing: 5.0, children: [ ExcludeSemantics( - child: ScalableText( + child: ThunderScalableText( l10n.in_, textScaleFactor: contentFontSizeScale.textScaleFactor, style: theme.textTheme.bodyMedium?.copyWith( diff --git a/lib/src/features/community/presentation/widgets/community_header/community_header.dart b/lib/src/features/community/presentation/widgets/community_header/community_header.dart index 84a21f9b9..118187e4b 100644 --- a/lib/src/features/community/presentation/widgets/community_header/community_header.dart +++ b/lib/src/features/community/presentation/widgets/community_header/community_header.dart @@ -8,7 +8,7 @@ import 'package:thunder/src/shared/name/full_name_widgets.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; import 'package:thunder/src/shared/media/image_preview.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderIconLabel; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays a community's header information and related actions. /// diff --git a/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart b/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart index a98b20946..c6a7d23f6 100644 --- a/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart +++ b/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart @@ -12,7 +12,7 @@ import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays relevant actions for a community in a scrollable chip list. class CommunityHeaderActions extends StatelessWidget { @@ -231,10 +231,10 @@ class _AnonymousSubscriptionChip extends StatelessWidget { if (isSubscribed) { context.read().removeSubscriptions({community.actorId}); - showSnackbar(l10n.unsubscribed); + showThunderSnackbar(l10n.unsubscribed); } else { context.read().addSubscriptions({community}); - showSnackbar(l10n.subscribed); + showThunderSnackbar(l10n.subscribed); } }, ); diff --git a/lib/src/features/community/presentation/widgets/community_information.dart b/lib/src/features/community/presentation/widgets/community_information.dart index 855e3bdae..f32d404e1 100644 --- a/lib/src/features/community/presentation/widgets/community_information.dart +++ b/lib/src/features/community/presentation/widgets/community_information.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; @@ -47,18 +47,18 @@ class CommunityInformation extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ CommunityHeader(community: community, instance: instance, moderators: moderators, condensed: true), - SidebarSectionHeader(value: l10n.information), + ThunderSectionHeader(title: l10n.information, variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: CommonMarkdownBody(body: community.description ?? '', imageMaxWidth: MediaQuery.of(context).size.width, launchContext: launchContext), ), - SidebarSectionHeader(value: l10n.stats), + ThunderSectionHeader(title: l10n.stats, variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: CommunityStatsList(community: community), ), if (moderators.isNotEmpty) ...[ - SidebarSectionHeader(value: l10n.moderator(2)), + ThunderSectionHeader(title: l10n.moderator(2), variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: CommunityModeratorList(launchContext: launchContext, account: account, moderators: moderators), @@ -86,50 +86,50 @@ class CommunityStatsList extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ...[ - SidebarStat( + ThunderSidebarStat( icon: community.status.local ? Icons.house_rounded : Icons.language_rounded, - value: l10n.visibility(community.status.local ? l10n.localOnly : l10n.public), + label: l10n.visibility(community.status.local ? l10n.localOnly : l10n.public), ), const SizedBox(height: 8.0), ], - SidebarStat( + ThunderSidebarStat( icon: Icons.cake_rounded, - value: '${l10n.created(DateFormat.yMMMMd().format(community.published))} · ${l10n.ago(formatTimeToString(dateTime: community.published.toIso8601String()))}', + label: '${l10n.created(DateFormat.yMMMMd().format(community.published))} · ${l10n.ago(formatTimeToString(dateTime: community.published.toIso8601String()))}', ), const SizedBox(height: 8.0), - SidebarStat( + ThunderSidebarStat( icon: Icons.people_rounded, - value: l10n.countSubscribers(NumberFormat("#,###,###,###").format(community.counts.subscribers)), + label: l10n.countSubscribers(NumberFormat("#,###,###,###").format(community.counts.subscribers)), ), if (community.counts.subscribersLocal != null) - SidebarStat( + ThunderSidebarStat( icon: Icons.people_rounded, - value: l10n.countLocalSubscribers(NumberFormat("#,###,###,###").format(community.counts.subscribersLocal)), + label: l10n.countLocalSubscribers(NumberFormat("#,###,###,###").format(community.counts.subscribersLocal)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.wysiwyg_rounded, - value: l10n.countPosts(NumberFormat("#,###,###,###").format(community.counts.posts)), + label: l10n.countPosts(NumberFormat("#,###,###,###").format(community.counts.posts)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.chat_rounded, - value: l10n.countComments(NumberFormat("#,###,###,###").format(community.counts.comments)), + label: l10n.countComments(NumberFormat("#,###,###,###").format(community.counts.comments)), ), const SizedBox(height: 8.0), - SidebarStat( + ThunderSidebarStat( icon: Icons.calendar_month_rounded, - value: l10n.countUsersActiveHalfYear(NumberFormat("#,###,###,###").format(community.counts.usersActiveHalfYear)), + label: l10n.countUsersActiveHalfYear(NumberFormat("#,###,###,###").format(community.counts.usersActiveHalfYear)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.calendar_view_month_rounded, - value: l10n.countUsersActiveMonth(NumberFormat("#,###,###,###").format(community.counts.usersActiveMonth)), + label: l10n.countUsersActiveMonth(NumberFormat("#,###,###,###").format(community.counts.usersActiveMonth)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.calendar_view_week_rounded, - value: l10n.countUsersActiveWeek(NumberFormat("#,###,###,###").format(community.counts.usersActiveWeek)), + label: l10n.countUsersActiveWeek(NumberFormat("#,###,###,###").format(community.counts.usersActiveWeek)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.calendar_view_day_rounded, - value: l10n.countUsersActiveDay(NumberFormat("#,###,###,###").format(community.counts.usersActiveDay)), + label: l10n.countUsersActiveDay(NumberFormat("#,###,###,###").format(community.counts.usersActiveDay)), ), ], ); diff --git a/lib/src/features/community/presentation/widgets/community_list_entry.dart b/lib/src/features/community/presentation/widgets/community_list_entry.dart index ab00349ad..40144cdc5 100644 --- a/lib/src/features/community/presentation/widgets/community_list_entry.dart +++ b/lib/src/features/community/presentation/widgets/community_list_entry.dart @@ -16,7 +16,7 @@ import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays a given community's information. This widget is generally used in a list. class CommunityListEntry extends StatefulWidget { @@ -123,7 +123,7 @@ class _CommunityListEntryState extends State { ? IconButton( onPressed: () { onSubscribe(community.context.subscribed != SubscriptionStatus.notSubscribed, isUserLoggedIn); - showSnackbar(community.context.subscribed == SubscriptionStatus.notSubscribed ? l10n.addedCommunityToSubscriptions : l10n.removedCommunityFromSubscriptions); + showThunderSnackbar(community.context.subscribed == SubscriptionStatus.notSubscribed ? l10n.addedCommunityToSubscriptions : l10n.removedCommunityFromSubscriptions); }, icon: Semantics( label: subscriptionButtonLabel, diff --git a/lib/src/features/community/presentation/widgets/post_card.dart b/lib/src/features/community/presentation/widgets/post_card.dart index a60fd62b6..bfd2aa1a9 100644 --- a/lib/src/features/community/presentation/widgets/post_card.dart +++ b/lib/src/features/community/presentation/widgets/post_card.dart @@ -14,7 +14,7 @@ import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/shared/gestures/swipe_utils.dart'; import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderMultiActionDismissible, ThunderSwipeAction, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Interactive feed card for a post. class PostCard extends StatefulWidget { @@ -121,7 +121,7 @@ class _PostCardState extends State { bool downvotesEnabled = context.read().state.downvotesEnabled; if (downvotesEnabled == false) { - showSnackbar(AppLocalizations.of(context)!.downvotesDisabled); + showThunderSnackbar(AppLocalizations.of(context)!.downvotesDisabled); return; } @@ -129,7 +129,7 @@ class _PostCardState extends State { return; case SwipeAction.reply: case SwipeAction.edit: - showSnackbar(AppLocalizations.of(context)!.replyNotSupported); + showThunderSnackbar(AppLocalizations.of(context)!.replyNotSupported); break; case SwipeAction.save: onSaveAction(post.id, !(saved ?? false)); diff --git a/lib/src/features/community/presentation/widgets/post_card_action_background.dart b/lib/src/features/community/presentation/widgets/post_card_action_background.dart index eb7068c17..1b26a971a 100644 --- a/lib/src/features/community/presentation/widgets/post_card_action_background.dart +++ b/lib/src/features/community/presentation/widgets/post_card_action_background.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/settings/api.dart'; @@ -48,14 +49,11 @@ class PostCardActionBackground extends StatelessWidget { final backgroundColor = swipeAction != null ? swipeAction!.getColor(context) : defaultColor.withValues(alpha: dismissThreshold / firstActionThreshold); final computedWidth = width * (tabletMode ? 0.5 : 1) * dismissThreshold; - return AnimatedContainer( + return ThunderSwipeActionBackground( alignment: alignment, - duration: const Duration(milliseconds: 200), - color: backgroundColor, - child: SizedBox( - width: computedWidth, - child: swipeAction != null ? Icon(swipeAction!.getIcon(read: read, hidden: hidden)) : const SizedBox.shrink(), - ), + backgroundColor: backgroundColor, + width: computedWidth, + icon: swipeAction?.getIcon(read: read, hidden: hidden), ); } } diff --git a/lib/src/features/community/presentation/widgets/post_card_metadata.dart b/lib/src/features/community/presentation/widgets/post_card_metadata.dart index c553942e8..dcba74679 100644 --- a/lib/src/features/community/presentation/widgets/post_card_metadata.dart +++ b/lib/src/features/community/presentation/widgets/post_card_metadata.dart @@ -12,12 +12,12 @@ import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/shared/avatars/community_avatar.dart'; import 'package:thunder/src/shared/name/full_name_widgets.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText, ThunderIconLabel; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// Contains metadata related to a given post. This is generally displayed as part of the post card. /// @@ -214,7 +214,7 @@ class ScorePostCardMetaData extends StatelessWidget { ), ), if (showScores) - ScalableText( + ThunderScalableText( formattedScore, semanticsLabel: l10n.xScore(formattedScore), textScaleFactor: metadataFontScale.textScaleFactor, diff --git a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart index 23549e125..2ee2af66a 100644 --- a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart +++ b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart @@ -10,9 +10,9 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/shared/media/media_view.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// Displays a card view of a post card. This view is used in the feed related pages. class PostCardViewComfortable extends StatelessWidget { @@ -259,7 +259,7 @@ class PostCardViewComfortable extends StatelessWidget { if (showTextContent && textContent.isNotEmpty) Padding( padding: showCommunityFirst ? edgesPadding : edgesPadding + const EdgeInsets.only(bottom: 6.0), - child: ScalableText( + child: ThunderScalableText( post.textPreview ?? textContent, maxLines: 4, overflow: TextOverflow.ellipsis, diff --git a/lib/src/features/feed/presentation/pages/feed_page.dart b/lib/src/features/feed/presentation/pages/feed_page.dart index bcc7ec169..6ee6e76a4 100644 --- a/lib/src/features/feed/presentation/pages/feed_page.dart +++ b/lib/src/features/feed/presentation/pages/feed_page.dart @@ -17,7 +17,7 @@ import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Creates a [FeedPage] which holds a list of posts for a given user, community, or custom feed. /// @@ -353,7 +353,7 @@ class _FeedViewState extends State { if (state.excessiveApiCalls && !_hasShownExcessiveApiCallsWarning) { _hasShownExcessiveApiCallsWarning = true; - showSnackbar( + showThunderSnackbar( l10n.excessiveApiCallsWarning, trailingIcon: Icons.settings_rounded, trailingAction: () => navigateToSettingPage(context, LocalSettings.settingsPageFilters, settingToHighlight: LocalSettings.keywordFilters), @@ -375,7 +375,7 @@ class _FeedViewState extends State { } if ((state.status == FeedStatus.failure || state.status == FeedStatus.failureLoadingCommunity || state.status == FeedStatus.failureLoadingUser) && state.message != null) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); context.read().add(FeedClearMessageEvent()); // Clear the message so that it does not spam } }, @@ -396,7 +396,9 @@ class _FeedViewState extends State { onChangeFeedType: (feedType) => setState(() => selectedSubview = feedType), ), const FeedFabOverlay(), - const FeedTopBarScrim(), + ThunderTopBarScrim( + visible: context.select((bloc) => bloc.state.hideTopBarOnScroll), + ), ], ), ), diff --git a/lib/src/features/feed/presentation/utils/community_feed_utils.dart b/lib/src/features/feed/presentation/utils/community_feed_utils.dart index 6a4abf1f4..174a02c66 100644 --- a/lib/src/features/feed/presentation/utils/community_feed_utils.dart +++ b/lib/src/features/feed/presentation/utils/community_feed_utils.dart @@ -10,7 +10,7 @@ import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/foundation/networking/networking.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; Future toggleFavoriteCommunity(BuildContext context, ThunderCommunity community, bool isFavorite) async { try { @@ -37,7 +37,7 @@ Future toggleFavoriteCommunity(BuildContext context, ThunderCommunity comm context.read().add(const FetchProfileFavorites()); } } catch (e) { - showSnackbar(getExceptionErrorMessage(e)); + showThunderSnackbar(getExceptionErrorMessage(e)); } } diff --git a/lib/src/features/feed/presentation/utils/feed_share_utils.dart b/lib/src/features/feed/presentation/utils/feed_share_utils.dart index ed9accc42..e1f5c664e 100644 --- a/lib/src/features/feed/presentation/utils/feed_share_utils.dart +++ b/lib/src/features/feed/presentation/utils/feed_share_utils.dart @@ -8,7 +8,8 @@ import 'package:thunder/src/features/feed/presentation/models/feed_share_options import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// Shows a bottom modal sheet which allows sharing the given [community]. Future showCommunityShareSheet(BuildContext context, ThunderCommunity community) async { @@ -17,30 +18,30 @@ Future showCommunityShareSheet(BuildContext context, ThunderCommunity comm final communityLink = await getLemmyCommunity(community.actorId) ?? ''; final lemmyLink = '!$communityLink'; - final localLink = 'https://${account.instance}/c/$communityLink'; + final localLink = buildInstanceUrl(account.instance, '/c/$communityLink'); if (context.mounted) { showModalBottomSheet( showDragHandle: true, isScrollControlled: true, context: context, - builder: (builderContext) => BottomSheetListPicker( + builder: (builderContext) => ThunderBottomSheetListPicker( title: l10n.shareCommunity, items: [ - ListPickerItem( + ThunderListPickerItem( label: l10n.shareCommunityLink, icon: Icons.link_rounded, subtitle: community.actorId, payload: CommunityShareOptions.link, ), if (!community.actorId.contains(account.instance)) - ListPickerItem( + ThunderListPickerItem( label: l10n.shareCommunityLinkLocal, icon: Icons.link_rounded, subtitle: localLink, payload: CommunityShareOptions.localLink, ), - ListPickerItem( + ThunderListPickerItem( label: l10n.shareLemmyLink, icon: Icons.share_rounded, subtitle: lemmyLink, @@ -78,30 +79,30 @@ Future showUserShareSheet(BuildContext context, ThunderUser person) async String user = await getLemmyUser(person.actorId) ?? ''; String lemmyLink = '@$user'; - String localLink = 'https://${account.instance}/u/$user'; + String localLink = buildInstanceUrl(account.instance, '/u/$user'); if (context.mounted) { showModalBottomSheet( showDragHandle: true, isScrollControlled: true, context: context, - builder: (builderContext) => BottomSheetListPicker( + builder: (builderContext) => ThunderBottomSheetListPicker( title: l10n.shareUser, items: [ - ListPickerItem( + ThunderListPickerItem( label: l10n.shareUserLink, payload: UserShareOptions.link, subtitle: person.actorId, icon: Icons.link_rounded, ), if (!person.actorId.contains(account.instance)) - ListPickerItem( + ThunderListPickerItem( label: l10n.shareUserLinkLocal, payload: UserShareOptions.localLink, subtitle: localLink, icon: Icons.link_rounded, ), - ListPickerItem( + ThunderListPickerItem( label: l10n.shareLemmyLink, payload: UserShareOptions.lemmy, subtitle: lemmyLink, diff --git a/lib/src/features/feed/presentation/widgets/feed_card_divider.dart b/lib/src/features/feed/presentation/widgets/feed_card_divider.dart index 6b7a4c0b5..21a21d0fe 100644 --- a/lib/src/features/feed/presentation/widgets/feed_card_divider.dart +++ b/lib/src/features/feed/presentation/widgets/feed_card_divider.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/features/feed/api.dart'; /// A user-customizable divider used between items (posts/comments) in the feed page. @@ -24,6 +25,12 @@ class FeedCardDivider extends StatelessWidget { color = Color.alphaBlend(theme.colorScheme.primaryContainer.withValues(alpha: 0.6), dividerColor).withValues(alpha: 0.2); } - return Divider(height: thickness, thickness: thickness, color: color); + return ThunderDivider( + sliver: false, + padding: false, + thickness: thickness, + height: thickness, + color: color, + ); } } diff --git a/lib/src/features/feed/presentation/widgets/feed_fab.dart b/lib/src/features/feed/presentation/widgets/feed_fab.dart index 27f9f31ec..5be7a4622 100644 --- a/lib/src/features/feed/presentation/widgets/feed_fab.dart +++ b/lib/src/features/feed/presentation/widgets/feed_fab.dart @@ -17,7 +17,7 @@ import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/shared/fabs/gesture_fab.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Floating action button menu for feed-level actions. class FeedFAB extends StatelessWidget { @@ -265,11 +265,11 @@ class FeedFAB extends StatelessWidget { final l10n = AppLocalizations.of(context)!; if (!context.read().state.isLoggedIn) { - return showSnackbar(l10n.mustBeLoggedInPost); + return showThunderSnackbar(l10n.mustBeLoggedInPost); } if (isPostingLocked) { - return showSnackbar(l10n.onlyModsCanPostInCommunity); + return showThunderSnackbar(l10n.onlyModsCanPostInCommunity); } FeedBloc feedBloc = context.read(); diff --git a/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart b/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart index 73e5f6127..9812d72fc 100644 --- a/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart +++ b/lib/src/features/feed/presentation/widgets/feed_initial_loading_sliver.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/ui.dart'; + /// Sliver shown while a feed is performing its initial load. class FeedInitialLoadingSliver extends StatelessWidget { const FeedInitialLoadingSliver({super.key}); @override Widget build(BuildContext context) { - return const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ); + return const ThunderStateView.loading(sliver: true); } } diff --git a/lib/src/features/feed/presentation/widgets/feed_reached_end.dart b/lib/src/features/feed/presentation/widgets/feed_reached_end.dart index 61d28c3b2..f420415b6 100644 --- a/lib/src/features/feed/presentation/widgets/feed_reached_end.dart +++ b/lib/src/features/feed/presentation/widgets/feed_reached_end.dart @@ -24,7 +24,7 @@ class FeedReachedEnd extends StatelessWidget { Container( color: theme.dividerColor.withValues(alpha: 0.1), padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( + child: ThunderScalableText( l10n.reachedTheBottom, textAlign: TextAlign.center, style: theme.textTheme.titleSmall, diff --git a/lib/src/features/feed/presentation/widgets/feed_top_bar_scrim.dart b/lib/src/features/feed/presentation/widgets/feed_top_bar_scrim.dart deleted file mode 100644 index 737798038..000000000 --- a/lib/src/features/feed/presentation/widgets/feed_top_bar_scrim.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; - -/// Safe-area scrim shown when the top app bar hides during feed scrolling. -class FeedTopBarScrim extends StatelessWidget { - const FeedTopBarScrim({super.key}); - - @override - Widget build(BuildContext context) { - final hideTopBarOnScroll = context.select((bloc) => bloc.state.hideTopBarOnScroll); - if (!hideTopBarOnScroll) return const SizedBox.shrink(); - - final theme = Theme.of(context); - final padding = MediaQuery.paddingOf(context).top; - - return Positioned( - child: Container( - height: padding, - color: theme.colorScheme.surface, - ), - ); - } -} diff --git a/lib/src/features/feed/presentation/widgets/widgets.dart b/lib/src/features/feed/presentation/widgets/widgets.dart index 3ce26e6de..55ed85f1d 100644 --- a/lib/src/features/feed/presentation/widgets/widgets.dart +++ b/lib/src/features/feed/presentation/widgets/widgets.dart @@ -16,4 +16,3 @@ export 'feed_header_sliver.dart'; export 'feed_initial_loading_sliver.dart'; export 'feed_scroll_body.dart'; export 'feed_reached_end.dart'; -export 'feed_top_bar_scrim.dart'; diff --git a/lib/src/features/inbox/presentation/pages/inbox_page.dart b/lib/src/features/inbox/presentation/pages/inbox_page.dart index 8afb5602b..47d804e67 100644 --- a/lib/src/features/inbox/presentation/pages/inbox_page.dart +++ b/lib/src/features/inbox/presentation/pages/inbox_page.dart @@ -10,7 +10,7 @@ import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem, showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the user's inbox replies, mentions, and private messages. class InboxPage extends StatefulWidget { @@ -123,7 +123,7 @@ class _InboxPageState extends State with SingleTickerProviderStateMix } if (state.errorMessage?.isNotEmpty == true) { - showSnackbar( + showThunderSnackbar( state.errorMessage!, trailingIcon: Icons.refresh_rounded, trailingAction: () => context.read().add(GetInboxEvent(inboxType: inboxType, reset: true, showAll: showAll)), diff --git a/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart b/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart index 62297ceda..5f23d57b0 100644 --- a/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart +++ b/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; +import 'package:thunder/packages/ui/ui.dart'; class InboxMentionsView extends StatefulWidget { final List mentions; diff --git a/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart b/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart index 419212f54..1c1a3dda7 100644 --- a/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart +++ b/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart @@ -10,7 +10,7 @@ import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; +import 'package:thunder/packages/ui/ui.dart'; class InboxRepliesView extends StatefulWidget { final List replies; diff --git a/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart b/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart index 4b226aed5..3c4889372 100644 --- a/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart +++ b/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart @@ -7,7 +7,7 @@ import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the actions that can be taken on an instance enum InstanceBottomSheetAction { @@ -121,7 +121,7 @@ class _InstanceActionBottomSheetState extends State { Navigator.of(context).pop(); final blocked = await repository.block(widget.communityInstanceId!, true); if (blocked) { - showSnackbar(l10n.successfullyBlockedCommunity(communityInstance!)); + showThunderSnackbar(l10n.successfullyBlockedCommunity(communityInstance!)); } widget.onAction?.call(); break; @@ -129,7 +129,7 @@ class _InstanceActionBottomSheetState extends State { Navigator.of(context).pop(); final blocked = await repository.block(widget.communityInstanceId!, false); if (!blocked) { - showSnackbar(l10n.successfullyUnblockedCommunity(communityInstance!)); + showThunderSnackbar(l10n.successfullyUnblockedCommunity(communityInstance!)); } widget.onAction?.call(); break; @@ -139,14 +139,14 @@ class _InstanceActionBottomSheetState extends State { case InstanceBottomSheetAction.blockUserInstance: Navigator.of(context).pop(); final blocked = await repository.block(widget.userInstanceId!, true); - if (blocked) showSnackbar(l10n.successfullyBlockedUser(userInstance!)); + if (blocked) showThunderSnackbar(l10n.successfullyBlockedUser(userInstance!)); widget.onAction?.call(); break; case InstanceBottomSheetAction.unblockUserInstance: Navigator.of(context).pop(); final blocked = await repository.block(widget.userInstanceId!, false); if (!blocked) { - showSnackbar(l10n.successfullyUnblockedUser(userInstance!)); + showThunderSnackbar(l10n.successfullyUnblockedUser(userInstance!)); } widget.onAction?.call(); break; @@ -218,7 +218,7 @@ class _InstanceActionBottomSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ ...userActions.map( - (instancePostAction) => BottomSheetAction( + (instancePostAction) => ThunderBottomSheetAction( leading: Icon(instancePostAction.icon), subtitle: switch (instancePostAction) { InstanceBottomSheetAction.visitCommunityInstance => communityInstance, diff --git a/lib/src/features/instance/presentation/widgets/instance_information.dart b/lib/src/features/instance/presentation/widgets/instance_information.dart index cca78f26d..291aa5219 100644 --- a/lib/src/features/instance/presentation/widgets/instance_information.dart +++ b/lib/src/features/instance/presentation/widgets/instance_information.dart @@ -6,7 +6,7 @@ import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; import 'package:thunder/src/shared/avatars/instance_avatar.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays information about a given instance. class InstanceInformation extends StatelessWidget { diff --git a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart index 659d65a47..681c10096 100644 --- a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart +++ b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart @@ -7,13 +7,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/foundation/networking/discovery/instance_discovery_service.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; class InstancePageAppBar extends StatefulWidget { /// The instance being displayed. @@ -67,7 +68,7 @@ class _InstancePageAppBarState extends State { final l10n = GlobalContext.l10n; final account = context.read().state.account; - final instanceHost = Uri.parse(widget.instance.domain).host; + final instanceHost = normalizeInstanceHost(widget.instance.domain) ?? widget.instance.domain; final blockedInstances = context.watch().state.siteResponse?.myUser?.instanceBlocks; final blocked = blockedInstances?.any((i) => instanceHost.contains(i.instance['domain'])) ?? false; @@ -132,9 +133,9 @@ class _InstancePageAppBarState extends State { if (context.mounted) { if (success) { - showSnackbar(l10n.successfullyBlockedCommunity(widget.instance.name)); + showThunderSnackbar(l10n.successfullyBlockedCommunity(widget.instance.name)); } else { - showSnackbar(l10n.successfullyUnblockedCommunity(widget.instance.name)); + showThunderSnackbar(l10n.successfullyUnblockedCommunity(widget.instance.name)); } } }, diff --git a/lib/src/features/instance/presentation/widgets/instance_tabs.dart b/lib/src/features/instance/presentation/widgets/instance_tabs.dart index 6d2b0474c..3e2375f5a 100644 --- a/lib/src/features/instance/presentation/widgets/instance_tabs.dart +++ b/lib/src/features/instance/presentation/widgets/instance_tabs.dart @@ -13,7 +13,7 @@ import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/features/instance/presentation/state/instance_page_bloc.dart'; import 'package:thunder/src/features/instance/presentation/state/instance_page_event.dart'; -import 'package:thunder/src/shared/error_message.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// A scaffold for instance tabs. Handles loading, retry and loading more. class _InstanceTabScaffold extends StatefulWidget { @@ -64,10 +64,15 @@ class _InstanceTabScaffoldState extends State<_InstanceTabScaffold> with A } if (state.status == InstancePageStatus.failure && state.items.isEmpty) { - return ErrorMessage( + return ThunderStateView( + title: l10n.somethingWentWrong, message: state.message, actions: [ - (text: l10n.refreshContent, action: widget.onRetry, loading: false), + ThunderStateAction( + label: l10n.refreshContent, + onPressed: widget.onRetry, + primary: true, + ), ], ); } diff --git a/lib/src/features/moderator/presentation/pages/report_page.dart b/lib/src/features/moderator/presentation/pages/report_page.dart index 4f809a48c..5ac3cd7ee 100644 --- a/lib/src/features/moderator/presentation/pages/report_page.dart +++ b/lib/src/features/moderator/presentation/pages/report_page.dart @@ -16,12 +16,11 @@ import 'package:thunder/src/features/comment/presentation/widgets/comment_refere import 'package:thunder/src/shared/name/full_name_widgets.dart'; import 'package:thunder/src/features/session/api.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Creates a [ReportFeedPage] which holds a list of reported posts/comments. class ReportFeedPage extends StatefulWidget { @@ -180,7 +179,7 @@ class _ReportFeedViewState extends State { }, listener: (context, state) { if ((state.status == ReportStatus.failure) && state.message != null) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); context.read().add(ReportFeedClearMessageEvent()); // Clear the message so that it does not spam } }, @@ -258,7 +257,7 @@ class _ReportFeedViewState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ScalableText( + ThunderScalableText( l10n.detailedReason(report.reason), maxLines: 4, overflow: TextOverflow.ellipsis, @@ -342,7 +341,7 @@ class _ReportFeedViewState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ScalableText( + ThunderScalableText( l10n.detailedReason(report.reason), maxLines: 4, overflow: TextOverflow.ellipsis, diff --git a/lib/src/features/modlog/presentation/pages/modlog_page.dart b/lib/src/features/modlog/presentation/pages/modlog_page.dart index cb9065ab7..8a6ed4a4d 100644 --- a/lib/src/features/modlog/presentation/pages/modlog_page.dart +++ b/lib/src/features/modlog/presentation/pages/modlog_page.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/features/modlog/modlog.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/foundation/config/config.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Creates a [ModlogPage] which holds a list of modlog events. class ModlogFeedPage extends StatefulWidget { @@ -140,7 +140,7 @@ class _ModlogFeedViewState extends State { } if ((state.status == ModlogStatus.failure) && state.message != null) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); context.read().clearMessage(); // Clear the message so that it does not spam } }, diff --git a/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart b/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart index 60fc66a24..43f07a7dc 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart @@ -3,72 +3,72 @@ import 'package:flutter/services.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, PickerItem; +import 'package:thunder/packages/ui/ui.dart'; -List> defaultModlogActionTypeItems = [ - ListPickerItem( +List> defaultModlogActionTypeItems = [ + ThunderListPickerItem( payload: ModlogActionType.all, icon: Icons.check_box_outline_blank, label: GlobalContext.l10n.all, ), ]; -List> postModlogActionTypeItems = [ - ListPickerItem( +List> postModlogActionTypeItems = [ + ThunderListPickerItem( payload: ModlogActionType.modRemovePost, icon: Icons.delete_rounded, label: GlobalContext.l10n.modRemovePost, ), - ListPickerItem( + ThunderListPickerItem( payload: ModlogActionType.modLockPost, icon: Icons.lock_person_rounded, label: GlobalContext.l10n.modLockPost, ), - ListPickerItem( + ThunderListPickerItem( payload: ModlogActionType.modFeaturePost, icon: Icons.push_pin_rounded, label: GlobalContext.l10n.modFeaturePost, ), ]; -List> commentModlogActionTypeItems = [ - ListPickerItem( +List> commentModlogActionTypeItems = [ + ThunderListPickerItem( payload: ModlogActionType.modRemoveComment, icon: Icons.comments_disabled_rounded, label: GlobalContext.l10n.modRemoveComment, ), ]; -List> communityModlogActionTypeItems = [ - ListPickerItem( +List> communityModlogActionTypeItems = [ + ThunderListPickerItem( payload: ModlogActionType.modRemoveCommunity, icon: Icons.domain_disabled_rounded, label: GlobalContext.l10n.modRemoveCommunity, ), - ListPickerItem( + ThunderListPickerItem( payload: ModlogActionType.modBanFromCommunity, icon: Icons.person_off_rounded, label: GlobalContext.l10n.modBanFromCommunity, ), - ListPickerItem( + ThunderListPickerItem( payload: ModlogActionType.modAddCommunity, icon: Icons.person_add_alt_1_rounded, label: GlobalContext.l10n.modAddCommunity, ), - ListPickerItem( + ThunderListPickerItem( payload: ModlogActionType.modTransferCommunity, icon: Icons.swap_horiz_rounded, label: GlobalContext.l10n.modTransferCommunity, ), ]; -List> instanceModlogActionTypeItems = [ - ListPickerItem( +List> instanceModlogActionTypeItems = [ + ThunderListPickerItem( payload: ModlogActionType.modAdd, icon: Icons.person_add_alt_1_rounded, label: GlobalContext.l10n.modAdd, ), - ListPickerItem( + ThunderListPickerItem( payload: ModlogActionType.modBan, icon: Icons.person_off_rounded, label: GlobalContext.l10n.modBan, @@ -77,12 +77,12 @@ List> instanceModlogActionTypeItems = [ /// Creates a [ModlogActionTypePicker] which holds a list of modlog action types. /// The modlog action type is used to filter the modlog events. -class ModlogActionTypePicker extends BottomSheetListPicker { +class ModlogActionTypePicker extends ThunderBottomSheetListPicker { ModlogActionTypePicker({ super.key, required super.onSelect, required super.title, - List>? items, + List>? items, super.previouslySelected, }) : super(items: items ?? defaultModlogActionTypeItems); @@ -157,7 +157,7 @@ class _ModlogActionTypePickerState extends State { physics: const NeverScrollableScrollPhysics(), children: [ ...defaultModlogActionTypeItems.map( - (item) => PickerItem( + (item) => ThunderPickerItem( label: item.label, icon: item.icon, onSelected: () { @@ -168,7 +168,7 @@ class _ModlogActionTypePickerState extends State { isSelected: widget.previouslySelected == item.payload, ), ), - PickerItem( + ThunderPickerItem( label: l10n.posts, icon: Icons.splitscreen_rounded, onSelected: () { @@ -178,7 +178,7 @@ class _ModlogActionTypePickerState extends State { isSelected: postModlogActionTypeItems.map((item) => item.payload).contains(widget.previouslySelected), trailingIcon: Icons.chevron_right, ), - PickerItem( + ThunderPickerItem( label: l10n.comments, icon: Icons.comment_rounded, onSelected: () { @@ -188,7 +188,7 @@ class _ModlogActionTypePickerState extends State { isSelected: commentModlogActionTypeItems.map((item) => item.payload).contains(widget.previouslySelected), trailingIcon: Icons.chevron_right, ), - PickerItem( + ThunderPickerItem( label: l10n.communities, icon: Icons.people_rounded, onSelected: () { @@ -198,7 +198,7 @@ class _ModlogActionTypePickerState extends State { isSelected: communityModlogActionTypeItems.map((item) => item.payload).contains(widget.previouslySelected), trailingIcon: Icons.chevron_right, ), - PickerItem( + ThunderPickerItem( label: GlobalContext.l10n.instance(1), icon: Icons.language_rounded, onSelected: () { @@ -221,13 +221,13 @@ class ModlogSubFilterPicker extends StatelessWidget { final String title; /// The list of modlog action types. - final List> items; + final List> items; /// The callback when the back button is pressed. final VoidCallback onNavigateBack; /// The callback when a modlog action type is selected. - final void Function(ListPickerItem) onSelect; + final void Function(ThunderListPickerItem) onSelect; /// The previously selected modlog action type. final ModlogActionType? previouslySelectedItem; @@ -291,7 +291,7 @@ class ModlogSubFilterPicker extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), children: items .map( - (item) => PickerItem( + (item) => ThunderPickerItem( label: item.label, icon: item.icon, onSelected: () { diff --git a/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart b/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart index 69e9f5e61..f7af52962 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart @@ -5,10 +5,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; +import 'package:thunder/packages/ui/ui.dart'; /// Widget to display a single modlog event item class ModlogItemCard extends StatelessWidget { @@ -77,7 +76,7 @@ class ModlogItemCard extends StatelessWidget { color: theme.colorScheme.onSurface, ), ), - ScalableText( + ThunderScalableText( event.getModlogEventTypeName(), style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), textScaleFactor: titleFontSizeScale.textScaleFactor, @@ -101,7 +100,7 @@ class ModlogItemCard extends StatelessWidget { Divider(thickness: 1.0, color: theme.dividerColor.withValues(alpha: 0.3)), Padding( padding: const EdgeInsets.only(bottom: 6.0), - child: ScalableText( + child: ThunderScalableText( l10n.detailedReason('${event.reason}'), maxLines: 4, overflow: TextOverflow.ellipsis, diff --git a/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart b/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart index b6c403fd2..0ead0966b 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart @@ -13,10 +13,9 @@ import 'package:thunder/src/shared/avatars/user_avatar.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; import 'package:thunder/src/shared/name/full_name_widgets.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Provides some additional context for a [ModlogEventItem] class ModlogItemContextCard extends StatelessWidget { @@ -100,7 +99,7 @@ class ModlogPostItemContextCard extends StatelessWidget { if (!post.status.removed) { navigateToPost(context, postId: post.id); } else { - showSnackbar(l10n.unableToFindPost); + showThunderSnackbar(l10n.unableToFindPost); } }, child: Container( @@ -112,7 +111,7 @@ class ModlogPostItemContextCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ScalableText( + ThunderScalableText( HtmlUnescape().convert(post.name), style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), textScaleFactor: titleFontSizeScale.textScaleFactor, @@ -186,11 +185,11 @@ class _ModlogCommentItemContextCardState extends State navigateToFeedPage(context, feedType: FeedType.user, userId: widget.user?.id), - child: ScalableText( + child: ThunderScalableText( '${widget.user?.displayName ?? widget.user?.displayNameOrName}', textScaleFactor: metadataFontSizeScale.textScaleFactor, style: theme.textTheme.bodyMedium?.copyWith(color: textStyleCommunityAndAuthor(theme.textTheme.bodyMedium?.color)), ), ), - ScalableText( + ThunderScalableText( ' in ', textScaleFactor: metadataFontSizeScale.textScaleFactor, style: theme.textTheme.bodyMedium?.copyWith( @@ -315,7 +314,7 @@ class ModlogUserItemContextCard extends StatelessWidget { if (user != null) { navigateToFeedPage(context, feedType: FeedType.user, userId: user!.id); } else { - showSnackbar(l10n.unableToFindUser); + showThunderSnackbar(l10n.unableToFindUser); } }, child: Container( @@ -331,7 +330,7 @@ class ModlogUserItemContextCard extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ScalableText( + ThunderScalableText( HtmlUnescape().convert(user?.displayName ?? user?.displayNameOrName ?? l10n.user), style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), textScaleFactor: titleFontSizeScale.textScaleFactor, @@ -368,7 +367,7 @@ class ModlogCommunityItemContextCard extends StatelessWidget { if (community != null && !community!.status.removed) { navigateToFeedPage(context, feedType: FeedType.community, communityId: community!.id); } else { - showSnackbar(l10n.unableToFindCommunity); + showThunderSnackbar(l10n.unableToFindCommunity); } }, child: Container( @@ -384,7 +383,7 @@ class ModlogCommunityItemContextCard extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ScalableText( + ThunderScalableText( HtmlUnescape().convert(community?.title ?? community?.name ?? l10n.community), style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), textScaleFactor: titleFontSizeScale.textScaleFactor, diff --git a/lib/src/features/notification/domain/models/notification_payload.dart b/lib/src/features/notification/domain/models/notification_payload.dart index eb3203e2f..f0f5da00e 100644 --- a/lib/src/features/notification/domain/models/notification_payload.dart +++ b/lib/src/features/notification/domain/models/notification_payload.dart @@ -1,100 +1,100 @@ -import 'package:thunder/src/features/notification/notification.dart'; - -class NotificationPayload { - /// The type of notification - final NotificationType type; - - /// A unique identifier for the inbox message that this notification corresponds to - /// Can be null if this is a group - final int? id; - - /// The identifier of the user to whom this notification was sent - final String accountId; - - /// The inbox type of this notification - final NotificationInboxType inboxType; - - /// Whether or not this notification is a group - final bool group; - - NotificationPayload({ - required this.type, - this.id, - required this.accountId, - required this.inboxType, - required this.group, - }); - - NotificationPayload.fromJson(Map json) - : type = NotificationType.values.byName(json['type'] as String), - id = json['id'] as int?, - accountId = json['accountId'] as String, - inboxType = NotificationInboxType.values.byName(json['inboxType'] as String), - group = json['group'] as bool; - - Map toJson() => { - 'type': type.name, - 'id': id, - 'accountId': accountId, - 'inboxType': inboxType.name, - 'group': group, - }; -} - -class NotificationGroupKey { - /// Corresponds to the user that these notifications are for - final String accountId; - - /// The type of inbox message - final NotificationInboxType inboxType; - - NotificationGroupKey({required this.accountId, required this.inboxType}); - - @override - String toString() => '$accountId-$inboxType'; -} - -/// Represents the payload generated by the Thunder server for UnifiedPush notifications for replies -class SlimCommentReplyView { - int commentReplyId; - String commentContent; - bool commentRemoved; - bool commentDeleted; - String creatorName; - String creatorActorId; - String postName; - String communityName; - String communityActorId; - String recipientName; - String recipientActorId; - - SlimCommentReplyView({ - required this.commentReplyId, - required this.commentContent, - required this.commentRemoved, - required this.commentDeleted, - required this.creatorName, - required this.creatorActorId, - required this.postName, - required this.communityName, - required this.communityActorId, - required this.recipientName, - required this.recipientActorId, - }); - - factory SlimCommentReplyView.fromJson(Map json) { - return SlimCommentReplyView( - commentReplyId: json['comment_reply_id'], - commentContent: json['comment_content'], - commentRemoved: json['comment_removed'], - commentDeleted: json['comment_deleted'], - creatorName: json['creator_name'], - creatorActorId: json['creator_actor_id'], - postName: json['post_name'], - communityName: json['community_name'], - communityActorId: json['community_actor_id'], - recipientName: json['recipient_name'], - recipientActorId: json['recipient_actor_id'], - ); - } -} +import 'package:thunder/src/features/notification/notification.dart'; + +class NotificationPayload { + /// The type of notification + final NotificationType type; + + /// A unique identifier for the inbox message that this notification corresponds to + /// Can be null if this is a group + final int? id; + + /// The identifier of the user to whom this notification was sent + final String accountId; + + /// The inbox type of this notification + final NotificationInboxType inboxType; + + /// Whether or not this notification is a group + final bool group; + + NotificationPayload({ + required this.type, + this.id, + required this.accountId, + required this.inboxType, + required this.group, + }); + + NotificationPayload.fromJson(Map json) + : type = NotificationType.values.byName(json['type'] as String), + id = json['id'] as int?, + accountId = json['accountId'] as String, + inboxType = NotificationInboxType.values.byName(json['inboxType'] as String), + group = json['group'] as bool; + + Map toJson() => { + 'type': type.name, + 'id': id, + 'accountId': accountId, + 'inboxType': inboxType.name, + 'group': group, + }; +} + +class NotificationGroupKey { + /// Corresponds to the user that these notifications are for + final String accountId; + + /// The type of inbox message + final NotificationInboxType inboxType; + + NotificationGroupKey({required this.accountId, required this.inboxType}); + + @override + String toString() => '$accountId-$inboxType'; +} + +/// Represents the payload generated by the Thunder server for UnifiedPush notifications for replies +class SlimCommentReplyView { + int commentReplyId; + String commentContent; + bool commentRemoved; + bool commentDeleted; + String creatorName; + String creatorActorId; + String postName; + String communityName; + String communityActorId; + String recipientName; + String recipientActorId; + + SlimCommentReplyView({ + required this.commentReplyId, + required this.commentContent, + required this.commentRemoved, + required this.commentDeleted, + required this.creatorName, + required this.creatorActorId, + required this.postName, + required this.communityName, + required this.communityActorId, + required this.recipientName, + required this.recipientActorId, + }); + + factory SlimCommentReplyView.fromJson(Map json) { + return SlimCommentReplyView( + commentReplyId: json['comment_reply_id'], + commentContent: json['comment_content'], + commentRemoved: json['comment_removed'], + commentDeleted: json['comment_deleted'], + creatorName: json['creator_name'], + creatorActorId: json['creator_actor_id'], + postName: json['post_name'], + communityName: json['community_name'], + communityActorId: json['community_actor_id'], + recipientName: json['recipient_name'], + recipientActorId: json['recipient_actor_id'], + ); + } +} diff --git a/lib/src/features/notification/presentation/utils/notification_content_utils.dart b/lib/src/features/notification/presentation/utils/notification_content_utils.dart index 71877e104..7c1f77b07 100644 --- a/lib/src/features/notification/presentation/utils/notification_content_utils.dart +++ b/lib/src/features/notification/presentation/utils/notification_content_utils.dart @@ -1,19 +1,19 @@ -// This Regex should match HTML img tags in the following formats, where the alt text is placed in Group 2 -// -// a -// -// -// a -// -// See: https://regex101.com/r/rSMP3Z/1 -RegExp imgTag = RegExp(r']*\s)?alt="([^"]*)"(?:\s[^>]*)?\/>|]*?)\/>'); - -/// Removes `img` tags from HTML content and replaces them with an italicize version of their alt text, or just the word "image". -String cleanImagesFromHtml(String htmlContent) { - return htmlContent.replaceAllMapped(imgTag, (match) { - if (match.group(2)?.isNotEmpty == true) { - return '${match.group(2)}'; - } - return 'image'; - }); -} +// This Regex should match HTML img tags in the following formats, where the alt text is placed in Group 2 +// +// a +// +// +// a +// +// See: https://regex101.com/r/rSMP3Z/1 +RegExp imgTag = RegExp(r']*\s)?alt="([^"]*)"(?:\s[^>]*)?\/>|]*?)\/>'); + +/// Removes `img` tags from HTML content and replaces them with an italicize version of their alt text, or just the word "image". +String cleanImagesFromHtml(String htmlContent) { + return htmlContent.replaceAllMapped(imgTag, (match) { + if (match.group(2)?.isNotEmpty == true) { + return '${match.group(2)}'; + } + return 'image'; + }); +} diff --git a/lib/src/features/notification/presentation/utils/notification_settings_utils.dart b/lib/src/features/notification/presentation/utils/notification_settings_utils.dart index 30bfa92e5..15e4194aa 100644 --- a/lib/src/features/notification/presentation/utils/notification_settings_utils.dart +++ b/lib/src/features/notification/presentation/utils/notification_settings_utils.dart @@ -13,7 +13,7 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// This function is used to update the notification settings. It is called when the user changes the notification settings. /// @@ -59,7 +59,7 @@ Future updateNotificationSettings( } else if (updatedNotificationType == NotificationType.none && !success) { // If we failed to remove all tokens from the server, we'll set the preference to NotificationType.none // The next time the app is opened, it will attempt to remove tokens from the server - showSnackbar(l10n.failedToCommunicateWithThunderNotificationServer(prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL)); + showThunderSnackbar(l10n.failedToCommunicateWithThunderNotificationServer(prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL)); onUpdate?.call(updatedNotificationType); return true; } @@ -104,7 +104,7 @@ Future updateNotificationSettings( if (areAndroidNotificationsAllowed != true) { areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); if (areAndroidNotificationsAllowed != true) { - showSnackbar( + showThunderSnackbar( l10n.permissionDenied, trailingIcon: Icons.settings_rounded, trailingAction: () async { @@ -138,7 +138,7 @@ Future updateNotificationSettings( if (notificationsEnabledOptions?.isEnabled != true) { bool? areIOSNotificationsAllowed = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); if (areIOSNotificationsAllowed != true) { - showSnackbar(l10n.permissionDenied); + showThunderSnackbar(l10n.permissionDenied); return Future.delayed(const Duration(seconds: 2)).then((_) => false); } } diff --git a/lib/src/features/post/presentation/pages/create_post_page.dart b/lib/src/features/post/presentation/pages/create_post_page.dart index e3abcffa3..60b8b323a 100644 --- a/lib/src/features/post/presentation/pages/create_post_page.dart +++ b/lib/src/features/post/presentation/pages/create_post_page.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:keyboard_detection/keyboard_detection.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/drafts/drafts.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -25,6 +24,7 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/shared/media/media_utils.dart' show isImageUrl, selectImagesToUpload; import 'package:thunder/src/shared/media/media_view.dart'; import 'package:thunder/src/shared/language_selector.dart'; +import 'package:thunder/packages/ui/ui.dart'; class CreatePostPage extends StatefulWidget { const CreatePostPage({ @@ -141,7 +141,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse if (widget.prePopulated == true && widget.url != null && widget.text?.isNotEmpty == true && widget.isCrossPost) { WidgetsBinding.instance.addPostFrameCallback((_) { final l10n = GlobalContext.l10n; - showSnackbar( + showThunderSnackbar( l10n.addOriginalPostBody, duration: const Duration(seconds: 10), trailingIcon: Icons.add_rounded, @@ -172,7 +172,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse await cubit.clearActiveDraft(); if (result == DraftPersistenceResult.saved && GlobalContext.scaffoldMessengerKey.currentState != null) { - showSnackbar(GlobalContext.l10n.postSavedAsDraft); + showThunderSnackbar(GlobalContext.l10n.postSavedAsDraft); } }(), ); @@ -250,7 +250,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse if (state.restoredDraftAvailable && state.restoredDraftNoticeId != _lastHandledDraftNoticeId) { _lastHandledDraftNoticeId = state.restoredDraftNoticeId; - showSnackbar( + showThunderSnackbar( GlobalContext.l10n.restoredPostFromDraft, trailingIcon: Icons.delete_forever_rounded, trailingIconColor: Theme.of(context).colorScheme.errorContainer, @@ -265,7 +265,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse } if (state.status == CreatePostStatus.error && state.message != null) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); context.read().clearMessage(); return; } @@ -274,17 +274,24 @@ class _CreatePostPageState extends State with WidgetsBindingObse case CreatePostStatus.imageUploadSuccess: final markdownImages = state.imageUrls?.map((url) => '![]($url)').join('\n\n') ?? ''; if (markdownImages.isEmpty) { + context.read().clearMessage(); return; } final selection = _bodyTextController.selection; final insertIndex = selection.isValid ? selection.end : _bodyTextController.text.length; - _bodyTextController.text = _bodyTextController.text.replaceRange(insertIndex, insertIndex, markdownImages); + final newBody = _bodyTextController.text.replaceRange(insertIndex, insertIndex, markdownImages); + + _syncingControllers = true; + _bodyTextController.text = newBody; _bodyTextController.selection = TextSelection.collapsed(offset: insertIndex + markdownImages.length); + _syncingControllers = false; + + context.read().updateBody(newBody); break; case CreatePostStatus.imageUploadFailure: case CreatePostStatus.postImageUploadFailure: - showSnackbar( + showThunderSnackbar( GlobalContext.l10n.postUploadImageError + (state.message?.isNotEmpty == true ? '. ${state.message}' : ''), leadingIcon: Icons.warning_rounded, leadingIconColor: Theme.of(context).colorScheme.errorContainer, diff --git a/lib/src/features/post/presentation/pages/post_page.dart b/lib/src/features/post/presentation/pages/post_page.dart index 9c916fc0f..e4cae2446 100644 --- a/lib/src/features/post/presentation/pages/post_page.dart +++ b/lib/src/features/post/presentation/pages/post_page.dart @@ -14,8 +14,8 @@ import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_fab_overlay.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_page_floating_action_button.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_page_scroll_body.dart'; -import 'package:thunder/src/features/post/presentation/widgets/post_top_bar_scrim.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// A page that displays the post details and comments associated with a post. class PostPage extends StatefulWidget { @@ -160,7 +160,7 @@ class _PostPageState extends State { return; } - showSnackbar(GlobalContext.l10n.noVisibleComments); + showThunderSnackbar(GlobalContext.l10n.noVisibleComments); } bool _listenWhen(PostState previous, PostState current) { @@ -190,7 +190,7 @@ class _PostPageState extends State { } if (state.status == PostPageStatus.failure) { - showSnackbar(state.errorMessage ?? l10n.missingErrorMessage); + showThunderSnackbar(state.errorMessage ?? l10n.missingErrorMessage); } _maybeScrollToHighlightedComment(state); @@ -277,7 +277,9 @@ class _PostPageState extends State { highlightedCommentId: highlightedCommentId, commentPath: widget.commentPath, ), - const PostTopBarScrim(), + ThunderTopBarScrim( + visible: context.select((cubit) => cubit.state.hideTopBarOnScroll), + ), const PostFabOverlay(), ], ), diff --git a/lib/src/features/post/presentation/state/create_post_cubit.dart b/lib/src/features/post/presentation/state/create_post_cubit.dart index 556590484..391036574 100644 --- a/lib/src/features/post/presentation/state/create_post_cubit.dart +++ b/lib/src/features/post/presentation/state/create_post_cubit.dart @@ -167,9 +167,13 @@ class CreatePostCubit extends Cubit { } void updateBody(String value) { - if (value == state.body) return; + final shouldClearImageUploadStatus = state.status == CreatePostStatus.imageUploadSuccess; + if (value == state.body && !shouldClearImageUploadStatus) return; - emit(state.copyWith(body: value)); + emit(state.copyWith( + body: value, + status: shouldClearImageUploadStatus ? CreatePostStatus.initial : state.status, + )); _scheduleDraftPersistence(); } diff --git a/lib/src/features/post/presentation/utils/user_label_dialog_utils.dart b/lib/src/features/post/presentation/utils/user_label_dialog_utils.dart index d91dabd76..97af43170 100644 --- a/lib/src/features/post/presentation/utils/user_label_dialog_utils.dart +++ b/lib/src/features/post/presentation/utils/user_label_dialog_utils.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// Shows a dialog which allows the user to create/modify/edit a label for the given [username]. /// Tip: Call `UserLabel.usernameFromParts` to generate a [username] in the right format. diff --git a/lib/src/features/post/presentation/widgets/create_post/create_post_metadata_row.dart b/lib/src/features/post/presentation/widgets/create_post/create_post_metadata_row.dart index 2ae5f7c6f..6c8da6769 100644 --- a/lib/src/features/post/presentation/widgets/create_post/create_post_metadata_row.dart +++ b/lib/src/features/post/presentation/widgets/create_post/create_post_metadata_row.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; class CreatePostMetadataRow extends StatelessWidget { @@ -22,24 +23,17 @@ class CreatePostMetadataRow extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = GlobalContext.l10n; - final width = MediaQuery.of(context).size.width; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ConstrainedBox( - constraints: BoxConstraints(maxWidth: width * 0.60), - child: languageSelector, - ), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - children: [ - Text(l10n.nsfw), - Switch(value: nsfw, onChanged: onNsfwChanged), - ], - ), - ], + return ThunderSplitActionRow( + leading: languageSelector, + trailing: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + children: [ + Text(l10n.nsfw), + Switch(value: nsfw, onChanged: onNsfwChanged), + ], + ), ); } } diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body.dart b/lib/src/features/post/presentation/widgets/post_body/post_body.dart index 7998ca5a5..93e6f4ec2 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body.dart @@ -14,7 +14,8 @@ import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart' import 'package:thunder/src/features/post/presentation/widgets/post_body/post_body_content_section.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_body/post_body_flair_section.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_body/post_body_media_section.dart'; -import 'package:thunder/src/shared/reply_to_preview_actions.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; @@ -278,11 +279,16 @@ class _PostBodyState extends State with SingleTickerProviderStateMixin } if (widget.showReplyEditorButtons && post.body?.isNotEmpty == true) { + final l10n = GlobalContext.l10n; children.add( - ReplyToPreviewActions( + ThunderPreviewActionRow( text: post.body!, viewSource: widget.viewSource, onViewSourceToggled: widget.onViewSourceToggled, + viewSourceLabel: l10n.viewSource, + viewOriginalLabel: l10n.viewOriginal, + copyLabel: l10n.copyText, + copiedMessage: l10n.copiedToClipboard, ), ); } diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart index 60782602d..be2e4a426 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart @@ -10,7 +10,7 @@ import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the quick actions bar for a post class PostBodyActionsBar extends StatefulWidget { @@ -193,7 +193,7 @@ class _PostBodyActionsBarState extends State { if (widget.locked) Expanded( child: IconButton( - onPressed: () => showSnackbar(l10n.postLocked), + onPressed: () => showThunderSnackbar(l10n.postLocked), icon: Icon(Icons.lock, semanticLabel: l10n.postLocked, color: theme.colorScheme.error), ), ), diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart index f61f846fd..39b9d808b 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_content_section.dart @@ -75,7 +75,7 @@ class PostBodyContentSection extends StatelessWidget { ), expanded: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: ConditionalParentWidget( + child: ThunderConditionalParent( condition: selectable, parentBuilder: (child) { return SelectableRegion( @@ -93,7 +93,7 @@ class PostBodyContentSection extends StatelessWidget { ); }, child: viewSource - ? ScalableText( + ? ThunderScalableText( post.body ?? '', style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), textScaleFactor: contentFontSizeScale.textScaleFactor, diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart index c65f1fc6f..a2192d42e 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart @@ -6,7 +6,7 @@ import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; +import 'package:thunder/packages/ui/ui.dart'; /// Provides a preview of the post body when the post is collapsed. /// @@ -53,7 +53,7 @@ class PostBodyPreview extends StatelessWidget { ); final content = viewSource - ? ScalableText( + ? ThunderScalableText( post.body ?? '', style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), textScaleFactor: contentFontSizeScale.textScaleFactor, diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart index f9cdc69e2..6c6f70840 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart @@ -8,11 +8,11 @@ import 'package:thunder/src/shared/avatars/user_avatar.dart'; import 'package:thunder/src/shared/chips/community_chip.dart'; import 'package:thunder/src/shared/chips/user_chip.dart'; import 'package:thunder/src/shared/media/compact_thumbnail_preview.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_flair_tags.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// Displays the title and related information for a given post. /// @@ -106,7 +106,7 @@ class PostBodyTitle extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ScalableText( + ThunderScalableText( post.name, textScaleFactor: titleFontSizeScale.textScaleFactor, style: theme.textTheme.titleMedium, diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart index 7d3b8df2f..070ef344d 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart @@ -7,7 +7,7 @@ import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the actions that can be taken on a community enum CommunityPostAction { @@ -117,7 +117,7 @@ class _CommunityPostActionBottomSheetState extends State( - (communityPostAction) => BottomSheetAction( + (communityPostAction) => ThunderBottomSheetAction( leading: Icon(communityPostAction.icon), title: communityPostAction.name, onTap: () => performAction(communityPostAction), @@ -202,12 +202,12 @@ class _CommunityPostActionBottomSheetState extends State( - (communityPostAction) => BottomSheetAction( + (communityPostAction) => ThunderBottomSheetAction( leading: Icon(communityPostAction.icon), trailing: Padding( padding: const EdgeInsets.only(left: 1), child: Icon( - Thunder.shield, + ThunderIcon.shield, size: 20, color: Color.alphaBlend(theme.colorScheme.primary.withValues(alpha: 0.4), Colors.green), ), diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/general_post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/general_post_action_bottom_sheet.dart index 8e141efed..4128b6b2a 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/general_post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/general_post_action_bottom_sheet.dart @@ -8,7 +8,7 @@ import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/shared/name/full_name_copy_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, MultiPickerItem, PickerItemData; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the general actions that can be taken on a post enum GeneralPostAction { @@ -271,8 +271,8 @@ class _GeneralPostActionBottomSheetPageState extends State((quickPostAction) { + ThunderMultiPickerItem( + pickerItems: quickActions.map((quickPostAction) { Function()? onSelected; if (quickPostAction == GeneralQuickPostAction.downvote && !widget.downvotesEnabled) { @@ -281,7 +281,7 @@ class _GeneralPostActionBottomSheetPageState extends State performAction(quickPostAction); } - return PickerItemData( + return ThunderMultiPickerItemData( icon: getIcon(quickPostAction), label: getLabel(quickPostAction), foregroundColor: getForegroundColor(quickPostAction), @@ -291,7 +291,7 @@ class _GeneralPostActionBottomSheetPageState extends State( - (page) => BottomSheetAction( + (page) => ThunderBottomSheetAction( leading: Icon(page.icon), trailing: const Icon(Icons.chevron_right_rounded), title: page.name, diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart index f208cc144..b9a7e4476 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart @@ -6,7 +6,7 @@ import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the actions that can be taken on a post enum PostPostAction { @@ -136,25 +136,25 @@ class _PostPostActionBottomSheetState extends State { case PostPostAction.deletePost: Navigator.of(context).pop(); final deleted = await repository.delete(widget.post.id, true); - if (deleted) showSnackbar(l10n.deletedPost); + if (deleted) showThunderSnackbar(l10n.deletedPost); widget.onAction(PostAction.delete, widget.post.copyWith(status: widget.post.status.copyWith(deleted: true))); break; case PostPostAction.restorePost: Navigator.of(context).pop(); final deleted = await repository.delete(widget.post.id, false); - if (!deleted) showSnackbar(l10n.restoredPost); + if (!deleted) showThunderSnackbar(l10n.restoredPost); widget.onAction(PostAction.delete, widget.post.copyWith(status: widget.post.status.copyWith(deleted: false))); break; case PostPostAction.lockPost: Navigator.of(context).pop(); final locked = await repository.lock(widget.post.id, true); - if (locked) showSnackbar(l10n.lockedPost); + if (locked) showThunderSnackbar(l10n.lockedPost); widget.onAction(PostAction.lock, widget.post.copyWith(status: widget.post.status.copyWith(locked: true))); break; case PostPostAction.unlockPost: Navigator.of(context).pop(); final locked = await repository.lock(widget.post.id, false); - if (!locked) showSnackbar(l10n.unlockedPost); + if (!locked) showThunderSnackbar(l10n.unlockedPost); widget.onAction(PostAction.lock, widget.post.copyWith(status: widget.post.status.copyWith(locked: false))); break; case PostPostAction.removePost: @@ -166,13 +166,13 @@ class _PostPostActionBottomSheetState extends State { case PostPostAction.pinPostToCommunity: Navigator.of(context).pop(); final pinned = await repository.pinCommunity(widget.post.id, true); - if (pinned) showSnackbar(l10n.pinnedPostToCommunity); + if (pinned) showThunderSnackbar(l10n.pinnedPostToCommunity); widget.onAction(PostAction.pinCommunity, widget.post.copyWith(status: widget.post.status.copyWith(featuredCommunity: true))); break; case PostPostAction.unpinPostFromCommunity: Navigator.of(context).pop(); final pinned = await repository.pinCommunity(widget.post.id, false); - if (!pinned) showSnackbar(l10n.unpinnedPostFromCommunity); + if (!pinned) showThunderSnackbar(l10n.unpinnedPostFromCommunity); widget.onAction(PostAction.pinCommunity, widget.post.copyWith(status: widget.post.status.copyWith(featuredCommunity: false))); break; } @@ -191,7 +191,7 @@ class _PostPostActionBottomSheetState extends State { final repository = PostRepositoryImpl(account: widget.account); await repository.report(widget.post.id, controller.text); - showSnackbar(l10n.reportedPost); + showThunderSnackbar(l10n.reportedPost); widget.onAction(PostAction.report, null); Navigator.of(dialogContext).pop(); @@ -223,7 +223,7 @@ class _PostPostActionBottomSheetState extends State { final repository = PostRepositoryImpl(account: widget.account); final removed = await repository.remove(widget.post.id, !widget.post.status.removed, controller.text); - removed ? showSnackbar(l10n.removedPost) : showSnackbar(l10n.restoredPost); + removed ? showThunderSnackbar(l10n.removedPost) : showThunderSnackbar(l10n.restoredPost); widget.onAction(PostAction.remove, widget.post.copyWith(status: widget.post.status.copyWith(removed: !widget.post.status.removed))); Navigator.of(dialogContext).pop(); @@ -294,7 +294,7 @@ class _PostPostActionBottomSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ ...userActions.map( - (postPostAction) => BottomSheetAction( + (postPostAction) => ThunderBottomSheetAction( leading: Icon(postPostAction.icon), title: postPostAction.name, onTap: () => performAction(postPostAction), @@ -303,12 +303,12 @@ class _PostPostActionBottomSheetState extends State { if (isModerator && moderatorActions.isNotEmpty) ...[ const ThunderDivider(sliver: false, padding: false), ...moderatorActions.map( - (postPostAction) => BottomSheetAction( + (postPostAction) => ThunderBottomSheetAction( leading: Icon(postPostAction.icon), trailing: Padding( padding: const EdgeInsets.only(left: 1), child: Icon( - Thunder.shield, + ThunderIcon.shield, size: 20, color: Color.alphaBlend(theme.colorScheme.primary.withValues(alpha: 0.4), Colors.green), ), diff --git a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart index 362165275..e5ebe0e91 100644 --- a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart +++ b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart @@ -11,7 +11,7 @@ import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/shared/links/link_bottom_sheet.dart'; -import 'package:thunder/packages/ui/ui.dart' show ListPickerItem, ThunderPopupMenuItem; +import 'package:thunder/packages/ui/ui.dart'; /// Holds the app bar for the post page. class PostPageAppBar extends StatelessWidget { @@ -225,7 +225,7 @@ class PostAppBarActions extends StatelessWidget { child: PopupMenuButton( itemBuilder: (context) => [ ThunderPopupMenuItem( - onTap: onCreateCrossPost, + onTap: () => onCreateCrossPost?.call(), icon: Icons.repeat_rounded, title: l10n.createNewCrossPost, ), @@ -235,7 +235,7 @@ class PostAppBarActions extends StatelessWidget { title: viewSource ? l10n.viewOriginal : l10n.viewPostSource, ), ThunderPopupMenuItem( - onTap: onSelectText, + onTap: () => onSelectText?.call(), icon: Icons.select_all_rounded, title: l10n.selectText, ), @@ -283,7 +283,7 @@ class PostAppBarActions extends StatelessWidget { return ('', null); } - ListPickerItem? commentSortTypeItem = getCommentSortTypeItems().firstWhereOrNull((item) => item.payload == state.commentSortType); + ThunderListPickerItem? commentSortTypeItem = getCommentSortTypeItems().firstWhereOrNull((item) => item.payload == state.commentSortType); return (commentSortTypeItem?.label ?? '', commentSortTypeItem?.icon); } diff --git a/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart b/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart index 9c391425e..4a9af5bbd 100644 --- a/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart +++ b/lib/src/features/post/presentation/widgets/post_page_content_slivers.dart @@ -3,11 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_body_sliver.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_bottom_sliver.dart'; import 'package:thunder/src/features/post/presentation/widgets/post_comments_sliver.dart'; -import 'package:thunder/src/features/post/presentation/widgets/post_page_error.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// Selects the post-page content branch and delegates each branch to slivers. class PostPageContentSlivers extends StatelessWidget { @@ -53,9 +54,22 @@ class PostPageContentSlivers extends StatelessWidget { } if (state.status == PostPageStatus.failure) { + final l10n = GlobalContext.l10n; return SliverFillRemaining( hasScrollBody: false, - child: PostPageError(onRetry: onRetry), + child: Center( + child: ThunderStateView( + title: l10n.unableToLoadPost, + message: l10n.internetOrInstanceIssues, + actions: [ + ThunderStateAction( + label: l10n.retry, + onPressed: onRetry, + primary: true, + ), + ], + ), + ), ); } diff --git a/lib/src/features/post/presentation/widgets/post_page_error.dart b/lib/src/features/post/presentation/widgets/post_page_error.dart deleted file mode 100644 index 19b92b442..000000000 --- a/lib/src/features/post/presentation/widgets/post_page_error.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/src/shared/error_message.dart'; - -/// Error state displayed when the post page cannot load its content. -class PostPageError extends StatelessWidget { - const PostPageError({super.key, required this.onRetry}); - - /// Called when the user taps the retry action. - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - - return Center( - child: ErrorMessage( - title: l10n.unableToLoadPost, - message: l10n.internetOrInstanceIssues, - actions: [ - ( - text: l10n.retry, - action: onRetry, - loading: false, - ), - ], - ), - ); - } -} diff --git a/lib/src/features/post/presentation/widgets/post_page_fab.dart b/lib/src/features/post/presentation/widgets/post_page_fab.dart index 1b7e4f221..6503e26c7 100644 --- a/lib/src/features/post/presentation/widgets/post_page_fab.dart +++ b/lib/src/features/post/presentation/widgets/post_page_fab.dart @@ -17,7 +17,7 @@ import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/shared/fabs/gesture_fab.dart'; import 'package:thunder/src/shared/fabs/comment_navigator_fab.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderTypeaheadDialog; +import 'package:thunder/packages/ui/ui.dart'; /// The FAB for the post page. class PostPageFAB extends StatelessWidget { @@ -64,8 +64,8 @@ class PostPageFAB extends StatelessWidget { final l10n = GlobalContext.l10n; final isLoggedIn = !resolveEffectiveAccount(context).anonymous; - if (postLocked) return showSnackbar(l10n.postLocked); - if (!isLoggedIn) return showSnackbar(l10n.mustBeLoggedInComment); + if (postLocked) return showThunderSnackbar(l10n.postLocked); + if (!isLoggedIn) return showThunderSnackbar(l10n.mustBeLoggedInComment); navigateToCreateCommentPage( context, @@ -114,7 +114,7 @@ class PostPageFAB extends StatelessWidget { } if (commentSearchResults.isEmpty) { - showSnackbar(l10n.noResultsFound); + showThunderSnackbar(l10n.noResultsFound); } else { context.read().startCommentSearch(commentSearchResults); } diff --git a/lib/src/features/post/presentation/widgets/post_page_feed_end.dart b/lib/src/features/post/presentation/widgets/post_page_feed_end.dart index 8d2d8e959..10c95e5c8 100644 --- a/lib/src/features/post/presentation/widgets/post_page_feed_end.dart +++ b/lib/src/features/post/presentation/widgets/post_page_feed_end.dart @@ -73,7 +73,7 @@ class _PostPageFeedEndState extends State { key: _reachedEndKey, color: theme.dividerColor.withValues(alpha: 0.1), padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( + child: ThunderScalableText( comments.isEmpty ? l10n.noCommentsFound : l10n.endOfComments, textScaleFactor: metadataFontSizeScale.textScaleFactor, textAlign: TextAlign.center, diff --git a/lib/src/features/post/presentation/widgets/post_top_bar_scrim.dart b/lib/src/features/post/presentation/widgets/post_top_bar_scrim.dart deleted file mode 100644 index a1405acb2..000000000 --- a/lib/src/features/post/presentation/widgets/post_top_bar_scrim.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; - -/// Covers the status-bar area when the top app bar scrolls away. -class PostTopBarScrim extends StatelessWidget { - const PostTopBarScrim({super.key}); - - @override - Widget build(BuildContext context) { - final hideTopBarOnScroll = context.select((cubit) => cubit.state.hideTopBarOnScroll); - if (!hideTopBarOnScroll) return const SizedBox.shrink(); - - final theme = Theme.of(context); - - return Positioned( - child: Container( - height: MediaQuery.paddingOf(context).top, - color: theme.colorScheme.surface, - ), - ); - } -} diff --git a/lib/src/features/private_message/presentation/pages/create_private_message_page.dart b/lib/src/features/private_message/presentation/pages/create_private_message_page.dart index 43804af82..5681137e8 100644 --- a/lib/src/features/private_message/presentation/pages/create_private_message_page.dart +++ b/lib/src/features/private_message/presentation/pages/create_private_message_page.dart @@ -111,7 +111,7 @@ class _CreatePrivateMessagePageState extends State { return BlocConsumer( listener: (context, state) { if (state.message?.isNotEmpty == true) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); } }, builder: (context, state) { diff --git a/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart b/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart index 32b759d93..bace4e4d8 100644 --- a/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart +++ b/lib/src/features/private_message/presentation/pages/private_message_thread_page.dart @@ -113,7 +113,7 @@ class _PrivateMessageThreadPageState extends State { listenWhen: (previous, current) => previous.messages.length != current.messages.length || previous.status != current.status || previous.message != current.message, listener: (context, state) { if (state.message?.isNotEmpty == true) { - showSnackbar(state.message!); + showThunderSnackbar(state.message!); } final messagesGrew = state.messages.length > _previousMessageCount; diff --git a/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart b/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart index 6e0b3fc91..2136ed14e 100644 --- a/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart +++ b/lib/src/features/private_message/presentation/widgets/thread/quick_reply_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; /// Reply field for sending a short direct-messages. @@ -32,46 +33,23 @@ class QuickReplyBar extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = GlobalContext.l10n; - final theme = Theme.of(context); - return Container( - padding: EdgeInsets.fromLTRB(16, 4, 12, 8 + MediaQuery.paddingOf(context).bottom), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(32.0), - ), - padding: const EdgeInsets.only(left: 0, right: 12, top: 4, bottom: 4), - child: Row( - children: [ - IconButton( - constraints: const BoxConstraints(minWidth: 20, minHeight: 20), - onPressed: onOpenComposer, - icon: Icon(Icons.add_circle_outline_rounded, semanticLabel: l10n.message(0)), - ), - Expanded( - child: TextField( - controller: controller, - minLines: 1, - maxLines: 4, - decoration: InputDecoration.collapsed(hintText: l10n.message(0)), - textInputAction: TextInputAction.newline, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 4), - IconButton.filledTonal( - onPressed: canSend ? onSend : null, - icon: sending ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2)) : Icon(Icons.send_rounded, semanticLabel: l10n.send, size: 22), - ), - ], + return ThunderComposerBar( + leading: IconButton( + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + onPressed: onOpenComposer, + icon: Icon(Icons.add_circle_outline_rounded, semanticLabel: l10n.message(0)), + ), + textField: TextField( + controller: controller, + minLines: 1, + maxLines: 4, + decoration: InputDecoration.collapsed(hintText: l10n.message(0)), + textInputAction: TextInputAction.newline, + ), + trailing: IconButton.filledTonal( + onPressed: canSend ? onSend : null, + icon: sending ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2)) : Icon(Icons.send_rounded, semanticLabel: l10n.send, size: 22), ), ); } diff --git a/lib/src/features/search/presentation/pages/search_page.dart b/lib/src/features/search/presentation/pages/search_page.dart index e5ed9a9b5..a897ff729 100644 --- a/lib/src/features/search/presentation/pages/search_page.dart +++ b/lib/src/features/search/presentation/pages/search_page.dart @@ -18,7 +18,7 @@ import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show ListPickerItem; +import 'package:thunder/packages/ui/ui.dart'; /// The main search page that handles search functionality. class SearchPage extends StatefulWidget { @@ -105,19 +105,19 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi return DEFAULT_SEARCH_SORT_TYPE; } - List> _searchSortItems() { + List> _searchSortItems() { return [ ...getDefaultSearchSortTypeItems(account: widget.account), ...getTopSearchSortTypeItems(account: widget.account), ]; } - ListPickerItem _resolveSearchSortItem(SearchSortType sortType) { + ThunderListPickerItem _resolveSearchSortItem(SearchSortType sortType) { final items = _searchSortItems(); final defaultItem = items.firstWhere( (item) => item.payload == DEFAULT_SEARCH_SORT_TYPE, - orElse: () => ListPickerItem( + orElse: () => ThunderListPickerItem( payload: DEFAULT_SEARCH_SORT_TYPE, icon: Icons.military_tech, label: GlobalContext.l10n.topYear, @@ -235,15 +235,15 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi search(); } - List> getSearchOptions(Account account) { + List> getSearchOptions(Account account) { final l10n = GlobalContext.l10n; - List> options = [ - ListPickerItem(label: l10n.communities, payload: MetaSearchType.communities, icon: Icons.people_rounded), - ListPickerItem(label: l10n.users, payload: MetaSearchType.users, icon: Icons.person_rounded), - ListPickerItem(label: l10n.posts, payload: MetaSearchType.posts, icon: Icons.wysiwyg_rounded), - ListPickerItem(label: l10n.comments, payload: MetaSearchType.comments, icon: Icons.chat_rounded), - ListPickerItem(label: l10n.instance(2), payload: MetaSearchType.instances, icon: Icons.language), + List> options = [ + ThunderListPickerItem(label: l10n.communities, payload: MetaSearchType.communities, icon: Icons.people_rounded), + ThunderListPickerItem(label: l10n.users, payload: MetaSearchType.users, icon: Icons.person_rounded), + ThunderListPickerItem(label: l10n.posts, payload: MetaSearchType.posts, icon: Icons.wysiwyg_rounded), + ThunderListPickerItem(label: l10n.comments, payload: MetaSearchType.comments, icon: Icons.chat_rounded), + ThunderListPickerItem(label: l10n.instance(2), payload: MetaSearchType.instances, icon: Icons.language), ]; // Only keep post/comment for community search diff --git a/lib/src/features/search/presentation/widgets/search_body.dart b/lib/src/features/search/presentation/widgets/search_body.dart index 7dd5688d7..034617761 100644 --- a/lib/src/features/search/presentation/widgets/search_body.dart +++ b/lib/src/features/search/presentation/widgets/search_body.dart @@ -14,8 +14,7 @@ import 'package:thunder/src/features/search/presentation/widgets/search_communit import 'package:thunder/src/features/search/presentation/widgets/search_instances_results.dart'; import 'package:thunder/src/features/search/presentation/widgets/search_posts_results.dart'; import 'package:thunder/src/features/search/presentation/widgets/search_users_results.dart'; -import 'package:thunder/src/shared/error_message.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip; +import 'package:thunder/packages/ui/ui.dart'; /// The main body content of the search page showing results based on search state. class SearchBody extends StatelessWidget { @@ -96,11 +95,21 @@ class SearchBody extends StatelessWidget { state: state, ); case SearchStatus.empty: - return Center(child: Text(l10n.empty)); + return ThunderStateView( + mode: ThunderStateViewMode.empty, + title: l10n.empty, + ); case SearchStatus.failure: - return _SearchErrorView( - errorMessage: state.message, - onRetry: onSearch, + return ThunderStateView( + title: l10n.somethingWentWrong, + message: state.message, + actions: [ + ThunderStateAction( + label: l10n.retry, + onPressed: onSearch, + primary: true, + ), + ], ); } } @@ -370,26 +379,3 @@ class _SearchNoResultsView extends StatelessWidget { ); } } - -/// Widget that displays an error message with retry option. -class _SearchErrorView extends StatelessWidget { - final String? errorMessage; - final VoidCallback onRetry; - - const _SearchErrorView({ - required this.errorMessage, - required this.onRetry, - }); - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - - return ErrorMessage( - message: errorMessage, - actions: [ - (text: l10n.retry, action: onRetry, loading: false), - ], - ); - } -} diff --git a/lib/src/features/search/presentation/widgets/search_filters_row.dart b/lib/src/features/search/presentation/widgets/search_filters_row.dart index bbf4c9ee9..c840d0ec2 100644 --- a/lib/src/features/search/presentation/widgets/search_filters_row.dart +++ b/lib/src/features/search/presentation/widgets/search_filters_row.dart @@ -11,12 +11,12 @@ import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, ThunderActionChip; +import 'package:thunder/packages/ui/ui.dart'; /// The horizontal filter chips row for search options. class SearchFiltersRow extends StatefulWidget { /// Available search type options. - final List> searchOptions; + final List> searchOptions; /// Limits the search to a specific community. final ThunderCommunity? community; @@ -128,7 +128,7 @@ class _SearchFiltersRowState extends State { /// Chip widget for selecting search type. class _SearchTypeChip extends StatelessWidget { - final List> searchOptions; + final List> searchOptions; final VoidCallback onSearch; const _SearchTypeChip({ @@ -150,7 +150,7 @@ class _SearchTypeChip extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (ctx) => BottomSheetListPicker( + builder: (ctx) => ThunderBottomSheetListPicker( title: l10n.selectSearchType, items: searchOptions, onSelect: (value) async { @@ -188,11 +188,11 @@ class _UrlTextChip extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (ctx) => BottomSheetListPicker( + builder: (ctx) => ThunderBottomSheetListPicker( title: l10n.searchPostSearchType, items: [ - ListPickerItem(label: l10n.searchByText, payload: 'text', icon: Icons.wysiwyg_rounded), - ListPickerItem(label: l10n.searchByUrl, payload: 'url', icon: Icons.link_rounded), + ThunderListPickerItem(label: l10n.searchByText, payload: 'text', icon: Icons.wysiwyg_rounded), + ThunderListPickerItem(label: l10n.searchByUrl, payload: 'url', icon: Icons.link_rounded), ], onSelect: (value) async { context.read().add(SearchFiltersUpdated(searchByUrl: value.payload == 'url')); @@ -255,12 +255,12 @@ class _FeedTypeChip extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (ctx) => BottomSheetListPicker( + builder: (ctx) => ThunderBottomSheetListPicker( title: l10n.selectFeedType, items: [ - ListPickerItem(label: l10n.subscribed, payload: FeedListType.subscribed, icon: Icons.view_list_rounded), - ListPickerItem(label: l10n.local, payload: FeedListType.local, icon: Icons.home_rounded), - ListPickerItem(label: l10n.all, payload: FeedListType.all, icon: Icons.grid_view_rounded), + ThunderListPickerItem(label: l10n.subscribed, payload: FeedListType.subscribed, icon: Icons.view_list_rounded), + ThunderListPickerItem(label: l10n.local, payload: FeedListType.local, icon: Icons.home_rounded), + ThunderListPickerItem(label: l10n.all, payload: FeedListType.all, icon: Icons.grid_view_rounded), ], onSelect: (value) async { context.read().add(SearchFiltersUpdated(feedListType: value.payload)); diff --git a/lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart index 147fe9da8..34083d888 100644 --- a/lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart @@ -4,7 +4,7 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; +import 'package:thunder/packages/ui/ui.dart'; class AppearanceSettingsPage extends StatelessWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart index 1dc99733b..c2b7d9ce5 100644 --- a/lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart @@ -365,10 +365,10 @@ class _CommentAppearanceSettingsPageState extends State setPreferences(LocalSettings.nestedCommentIndicatorStyle, value.payload.name), @@ -378,10 +378,10 @@ class _CommentAppearanceSettingsPageState extends State setPreferences(LocalSettings.nestedCommentIndicatorColor, value.payload.name), @@ -392,10 +392,7 @@ class _CommentAppearanceSettingsPageState extends State navigateToSettingPage(context, LocalSettings.settingsPageAppearanceTheming, settingToHighlight: LocalSettings.userStyle), highlightKey: settingToHighlightKey, highlighted: false), diff --git a/lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart index 1138086c3..2226ec40e 100644 --- a/lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart @@ -601,10 +601,10 @@ class _PostAppearanceSettingsPageState extends State ), ThunderListOption( title: l10n.postViewType, - value: ListPickerItem(label: useCompactView ? l10n.compactView : l10n.cardView, icon: Icons.crop_16_9_rounded, payload: useCompactView), + value: ThunderListPickerItem(label: useCompactView ? l10n.compactView : l10n.cardView, icon: Icons.crop_16_9_rounded, payload: useCompactView), options: [ - ListPickerItem(icon: Icons.crop_16_9_rounded, label: l10n.compactView, payload: true), - ListPickerItem(icon: Icons.crop_din_rounded, label: l10n.cardView, payload: false), + ThunderListPickerItem(icon: Icons.crop_16_9_rounded, label: l10n.compactView, payload: true), + ThunderListPickerItem(icon: Icons.crop_din_rounded, label: l10n.cardView, payload: false), ], leading: Icon(Icons.view_list_rounded), onChanged: (value) async => setPreferences(LocalSettings.useCompactView, value.payload), @@ -689,7 +689,7 @@ class _PostAppearanceSettingsPageState extends State ThunderListOption( title: l10n.dateFormat, disabled: !showFullPostDate, - value: ListPickerItem( + value: ThunderListPickerItem( label: (selectedDateFormat == null || selectedDateFormat!.pattern == dateFormats.first.pattern) ? l10n.system : selectedDateFormat!.pattern!, icon: Icons.access_time_filled_rounded, payload: selectedDateFormat, @@ -697,7 +697,7 @@ class _PostAppearanceSettingsPageState extends State ), options: dateFormats .map( - (DateFormat dateFormat) => ListPickerItem( + (DateFormat dateFormat) => ThunderListPickerItem( icon: Icons.access_time_filled_rounded, label: dateFormat.format(DateTime.now()), payload: dateFormat, @@ -712,7 +712,7 @@ class _PostAppearanceSettingsPageState extends State highlighted: settingToHighlight == LocalSettings.dateFormat), ThunderListOption( title: l10n.dividerAppearance, - value: const ListPickerItem(payload: -1), + value: const ThunderListPickerItem(payload: -1), options: const [], leading: Icon(Icons.splitscreen_rounded), highlightKey: settingToHighlightKey, @@ -720,7 +720,7 @@ class _PostAppearanceSettingsPageState extends State highlighted: settingToHighlight == LocalSettings.dividerAppearance, customListPicker: StatefulBuilder( builder: (context, setState) { - return BottomSheetListPicker( + return ThunderBottomSheetListPicker( title: l10n.dividerAppearance, heading: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -732,7 +732,7 @@ class _PostAppearanceSettingsPageState extends State ], ), items: [ - ListPickerItem( + ThunderListPickerItem( customWidget: ListTile( title: Text(l10n.thickness), contentPadding: const EdgeInsets.only(left: 24.0, right: 20.0), @@ -748,7 +748,7 @@ class _PostAppearanceSettingsPageState extends State ), payload: -1, ), - ListPickerItem( + ThunderListPickerItem( customWidget: ListTile( title: Text(l10n.color), contentPadding: const EdgeInsets.only(left: 24.0, right: 20.0), @@ -1021,7 +1021,7 @@ class _PostAppearanceSettingsPageState extends State highlighted: settingToHighlight == LocalSettings.showCrossPosts), ThunderListOption( title: l10n.postBodyViewType, - value: ListPickerItem( + value: ThunderListPickerItem( label: switch (postBodyViewType) { PostBodyViewType.condensed => l10n.condensed, PostBodyViewType.expanded => l10n.expanded, @@ -1030,8 +1030,8 @@ class _PostAppearanceSettingsPageState extends State payload: postBodyViewType, capitalizeLabel: false), options: [ - ListPickerItem(icon: Icons.crop_16_9_rounded, label: l10n.condensed, payload: PostBodyViewType.condensed), - ListPickerItem(icon: Icons.crop_din_rounded, label: l10n.expanded, payload: PostBodyViewType.expanded), + ThunderListPickerItem(icon: Icons.crop_16_9_rounded, label: l10n.condensed, payload: PostBodyViewType.condensed), + ThunderListPickerItem(icon: Icons.crop_din_rounded, label: l10n.expanded, payload: PostBodyViewType.expanded), ], leading: Icon(Icons.view_list_rounded), onChanged: (value) async => setPreferences(LocalSettings.postBodyViewType, value.payload), @@ -1139,7 +1139,7 @@ class _PostAppearanceSettingsPageState extends State const SizedBox(height: 6.0), ], if (showTextContent) ...[ - ScalableText( + ThunderScalableText( l10n.placeholderText, maxLines: 4, overflow: TextOverflow.ellipsis, diff --git a/lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart index db6f5b426..3a8f98283 100644 --- a/lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart @@ -102,13 +102,13 @@ class _ThemeSettingsPageState extends State { // For now, we will use the pre-made themes provided by FlexScheme - List customThemeOptions = [ - ListPickerItem( + List customThemeOptions = [ + ThunderListPickerItem( colors: [CustomThemeType.deepBlue.primaryColor, CustomThemeType.deepBlue.secondaryColor, CustomThemeType.deepBlue.tertiaryColor], label: '${CustomThemeType.deepBlue.label} (Default)', payload: CustomThemeType.deepBlue), ...CustomThemeType.values.where((element) => element != CustomThemeType.deepBlue).map((CustomThemeType scheme) { - return ListPickerItem(colors: [scheme.primaryColor, scheme.secondaryColor, scheme.tertiaryColor], label: scheme.label, payload: scheme); + return ThunderListPickerItem(colors: [scheme.primaryColor, scheme.secondaryColor, scheme.tertiaryColor], label: scheme.label, payload: scheme); }) ]; @@ -126,10 +126,10 @@ class _ThemeSettingsPageState extends State { FontScale metadataFontSizeScale = FontScale.base; /// Theme - this is initialized in initState since we need to get l10n for localization strings - List themeOptions = []; + List themeOptions = []; /// Font size scales - List fontScaleOptions = []; + List fontScaleOptions = []; /// Defines the separator used to denote full usernames FullNameSeparator userSeparator = FullNameSeparator.at; @@ -361,7 +361,7 @@ class _ThemeSettingsPageState extends State { setState(() { fontScaleOptions = FontScale.values .map( - (FontScale fontScale) => ListPickerItem( + (FontScale fontScale) => ThunderListPickerItem( icon: Icons.text_fields_rounded, label: fontScale.label, payload: fontScale, @@ -379,9 +379,9 @@ class _ThemeSettingsPageState extends State { @override void initState() { themeOptions = [ - ListPickerItem(icon: Icons.phonelink_setup_rounded, label: l10n.system, payload: ThemeType.system), - ListPickerItem(icon: Icons.light_mode_rounded, label: l10n.light, payload: ThemeType.light), - ListPickerItem(icon: Icons.dark_mode_outlined, label: l10n.dark, payload: ThemeType.dark), + ThunderListPickerItem(icon: Icons.phonelink_setup_rounded, label: l10n.system, payload: ThemeType.system), + ThunderListPickerItem(icon: Icons.light_mode_rounded, label: l10n.light, payload: ThemeType.light), + ThunderListPickerItem(icon: Icons.dark_mode_outlined, label: l10n.dark, payload: ThemeType.dark), ]; WidgetsBinding.instance.addPostFrameCallback((_) => _initPreferences()); @@ -436,7 +436,7 @@ class _ThemeSettingsPageState extends State { ), ThunderListOption( title: l10n.theme, - value: ListPickerItem(label: themeType.name.capitalize, icon: Icons.wallpaper_rounded, payload: themeType), + value: ThunderListPickerItem(label: themeType.name.capitalize, icon: Icons.wallpaper_rounded, payload: themeType), options: themeOptions, leading: Icon(Icons.wallpaper_rounded), onChanged: (value) async => setPreferences(LocalSettings.appTheme, value.payload.index), @@ -461,7 +461,7 @@ class _ThemeSettingsPageState extends State { ), ThunderListOption( title: l10n.themeAccentColor, - value: ListPickerItem(label: selectedTheme.label, icon: Icons.wallpaper_rounded, payload: selectedTheme), + value: ThunderListPickerItem(label: selectedTheme.label, icon: Icons.wallpaper_rounded, payload: selectedTheme), valueDisplay: Stack( children: [ Container( @@ -546,7 +546,7 @@ class _ThemeSettingsPageState extends State { ), ThunderListOption( title: l10n.postTitleFontScale, - value: ListPickerItem(label: titleFontSizeScale.name.capitalize, icon: Icons.feed, payload: titleFontSizeScale), + value: ThunderListPickerItem(label: titleFontSizeScale.name.capitalize, icon: Icons.feed, payload: titleFontSizeScale), options: fontScaleOptions, leading: Icon(Icons.text_fields_rounded), onChanged: (value) async => setPreferences(LocalSettings.titleFontSizeScale, value.payload), @@ -555,7 +555,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.titleFontSizeScale), ThunderListOption( title: l10n.postContentFontScale, - value: ListPickerItem(label: contentFontSizeScale.name.capitalize, icon: Icons.feed, payload: contentFontSizeScale), + value: ThunderListPickerItem(label: contentFontSizeScale.name.capitalize, icon: Icons.feed, payload: contentFontSizeScale), options: fontScaleOptions, leading: Icon(Icons.text_fields_rounded), onChanged: (value) async => setPreferences(LocalSettings.contentFontSizeScale, value.payload), @@ -564,7 +564,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.contentFontSizeScale), ThunderListOption( title: l10n.commentFontScale, - value: ListPickerItem(label: commentFontSizeScale.name.capitalize, icon: Icons.feed, payload: commentFontSizeScale), + value: ThunderListPickerItem(label: commentFontSizeScale.name.capitalize, icon: Icons.feed, payload: commentFontSizeScale), options: fontScaleOptions, leading: Icon(Icons.text_fields_rounded), onChanged: (value) async => setPreferences(LocalSettings.commentFontSizeScale, value.payload), @@ -573,7 +573,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.commentFontSizeScale), ThunderListOption( title: l10n.metadataFontScale, - value: ListPickerItem(label: metadataFontSizeScale.name.capitalize, icon: Icons.feed, payload: metadataFontSizeScale), + value: ThunderListPickerItem(label: metadataFontSizeScale.name.capitalize, icon: Icons.feed, payload: metadataFontSizeScale), options: fontScaleOptions, leading: Icon(Icons.text_fields_rounded), onChanged: (value) async => setPreferences(LocalSettings.metadataFontSizeScale, value.payload), @@ -595,7 +595,7 @@ class _ThemeSettingsPageState extends State { ), ThunderListOption( title: l10n.userFormat, - value: ListPickerItem( + value: ThunderListPickerItem( label: _generateSampleUserFullName(userSeparator, useDisplayNamesForUsers), labelWidget: _generateSampleUserFullNameWidget( userSeparator, @@ -611,7 +611,7 @@ class _ThemeSettingsPageState extends State { capitalizeLabel: false, ), options: [ - ListPickerItem( + ThunderListPickerItem( icon: const IconData(0x2022), label: _generateSampleUserFullName(FullNameSeparator.dot, useDisplayNamesForUsers), labelWidget: _generateSampleUserFullNameWidget( @@ -626,7 +626,7 @@ class _ThemeSettingsPageState extends State { payload: FullNameSeparator.dot, capitalizeLabel: false, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.alternate_email_rounded, label: _generateSampleUserFullName(FullNameSeparator.at, useDisplayNamesForUsers), labelWidget: _generateSampleUserFullNameWidget( @@ -641,7 +641,7 @@ class _ThemeSettingsPageState extends State { payload: FullNameSeparator.at, capitalizeLabel: false, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.alternate_email_rounded, label: _generateSampleUserFullName(FullNameSeparator.lemmy, useDisplayNamesForUsers), labelWidget: _generateSampleUserFullNameWidget( @@ -664,7 +664,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.userFormat), ThunderListOption( isBottomModalScrollControlled: true, - value: const ListPickerItem(payload: -1), + value: const ThunderListPickerItem(payload: -1), options: const [], title: l10n.userStyle, leading: Icon(Icons.person_rounded), @@ -673,7 +673,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.userStyle, customListPicker: StatefulBuilder( builder: (context, setState) { - return BottomSheetListPicker( + return ThunderBottomSheetListPicker( title: l10n.userStyle, heading: _generateSampleUserFullNameWidget( userSeparator, @@ -685,7 +685,7 @@ class _ThemeSettingsPageState extends State { useDisplayName: useDisplayNamesForUsers, ), items: [ - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -707,7 +707,7 @@ class _ThemeSettingsPageState extends State { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -729,7 +729,7 @@ class _ThemeSettingsPageState extends State { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -772,7 +772,7 @@ class _ThemeSettingsPageState extends State { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -821,7 +821,7 @@ class _ThemeSettingsPageState extends State { )), ThunderListOption( title: l10n.communityFormat, - value: ListPickerItem( + value: ThunderListPickerItem( label: _generateSampleCommunityFullName(communitySeparator, useDisplayNamesForCommunities), labelWidget: _generateSampleCommunityFullNameWidget( communitySeparator, @@ -837,7 +837,7 @@ class _ThemeSettingsPageState extends State { capitalizeLabel: false, ), options: [ - ListPickerItem( + ThunderListPickerItem( icon: const IconData(0x2022), label: _generateSampleCommunityFullName(FullNameSeparator.dot, useDisplayNamesForCommunities), labelWidget: _generateSampleCommunityFullNameWidget( @@ -852,7 +852,7 @@ class _ThemeSettingsPageState extends State { payload: FullNameSeparator.dot, capitalizeLabel: false, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.alternate_email_rounded, label: _generateSampleCommunityFullName(FullNameSeparator.at, useDisplayNamesForCommunities), labelWidget: _generateSampleCommunityFullNameWidget( @@ -867,7 +867,7 @@ class _ThemeSettingsPageState extends State { payload: FullNameSeparator.at, capitalizeLabel: false, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.alternate_email_rounded, label: _generateSampleCommunityFullName(FullNameSeparator.lemmy, useDisplayNamesForCommunities), labelWidget: _generateSampleCommunityFullNameWidget( @@ -890,7 +890,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.communityFormat), ThunderListOption( isBottomModalScrollControlled: true, - value: const ListPickerItem(payload: -1), + value: const ThunderListPickerItem(payload: -1), options: const [], title: l10n.communityStyle, leading: Icon(Icons.person_rounded), @@ -899,7 +899,7 @@ class _ThemeSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.communityStyle, customListPicker: StatefulBuilder( builder: (context, setState) { - return BottomSheetListPicker( + return ThunderBottomSheetListPicker( title: l10n.communityStyle, heading: _generateSampleCommunityFullNameWidget( communitySeparator, @@ -911,7 +911,7 @@ class _ThemeSettingsPageState extends State { useDisplayName: useDisplayNamesForCommunities, ), items: [ - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -933,7 +933,7 @@ class _ThemeSettingsPageState extends State { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -955,7 +955,7 @@ class _ThemeSettingsPageState extends State { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -998,7 +998,7 @@ class _ThemeSettingsPageState extends State { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( diff --git a/lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart index 3eca0408d..31df9dcc9 100644 --- a/lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart @@ -601,11 +601,11 @@ class _FabSettingsPage extends State with TickerProviderStateMi showModalBottomSheet( context: context, showDragHandle: true, - builder: (context) => BottomSheetListPicker( + builder: (context) => ThunderBottomSheetListPicker( title: l10n.setAction, items: [ - ListPickerItem(label: l10n.setShortPress, payload: 'short', icon: Icons.touch_app_outlined), - ListPickerItem(label: l10n.setLongPress, payload: 'long', icon: Icons.touch_app_rounded), + ThunderListPickerItem(label: l10n.setShortPress, payload: 'short', icon: Icons.touch_app_outlined), + ThunderListPickerItem(label: l10n.setLongPress, payload: 'long', icon: Icons.touch_app_rounded), ], onSelect: (value) async { if (value.payload == 'short') { @@ -625,11 +625,11 @@ class _FabSettingsPage extends State with TickerProviderStateMi showModalBottomSheet( context: context, showDragHandle: true, - builder: (context) => BottomSheetListPicker( + builder: (context) => ThunderBottomSheetListPicker( title: l10n.setAction, items: [ - ListPickerItem(label: l10n.setShortPress, payload: 'short', icon: Icons.touch_app_outlined), - ListPickerItem(label: l10n.setLongPress, payload: 'long', icon: Icons.touch_app_rounded), + ThunderListPickerItem(label: l10n.setShortPress, payload: 'short', icon: Icons.touch_app_outlined), + ThunderListPickerItem(label: l10n.setLongPress, payload: 'long', icon: Icons.touch_app_rounded), ], onSelect: (value) async { if (value.payload == 'short') { diff --git a/lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart index 4b3413724..00f08d8d7 100644 --- a/lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart @@ -158,7 +158,7 @@ class _FilterSettingsPageState extends State with SingleTick itemBuilder: (context, index) { return ThunderSettingsTile( title: keywordFilters[index], - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { showThunderDialog( context: context, @@ -182,10 +182,7 @@ class _FilterSettingsPageState extends State with SingleTick ThunderSettingsTile( leading: Icon(Icons.language), title: l10n.languageFilters, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () { // Can only set discussion language if user is logged in if (profileState.isLoggedIn && profileState.status == ProfileStatus.success && profileState.user != null) { diff --git a/lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart index eb4967632..42be4ca05 100644 --- a/lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart @@ -376,10 +376,10 @@ class _GeneralSettingsPageState extends State with SingleTi ), ThunderListOption( title: l10n.defaultFeedType, - value: ListPickerItem(label: defaultFeedListType.value, icon: Icons.feed, payload: defaultFeedListType), + value: ThunderListPickerItem(label: defaultFeedListType.value, icon: Icons.feed, payload: defaultFeedListType), options: [ - ListPickerItem(icon: Icons.home_rounded, label: FeedListType.all.value, payload: FeedListType.all), - ListPickerItem(icon: Icons.grid_view_rounded, label: FeedListType.local.value, payload: FeedListType.local), + ThunderListPickerItem(icon: Icons.home_rounded, label: FeedListType.all.value, payload: FeedListType.all), + ThunderListPickerItem(icon: Icons.grid_view_rounded, label: FeedListType.local.value, payload: FeedListType.local), ], leading: Icon(Icons.filter_alt_rounded), onChanged: (value) => setPreferences(LocalSettings.defaultFeedListType, value.payload.name), @@ -388,7 +388,7 @@ class _GeneralSettingsPageState extends State with SingleTi highlighted: settingToHighlight == LocalSettings.defaultFeedListType), ThunderListOption( title: l10n.defaultFeedSortType, - value: ListPickerItem( + value: ThunderListPickerItem( label: allPostSortTypeItems.firstWhere((item) => item.payload == defaultPostSortType).label, icon: Icons.local_fire_department_rounded, payload: defaultPostSortType, @@ -429,10 +429,7 @@ class _GeneralSettingsPageState extends State with SingleTi ThunderSettingsTile( leading: Icon(Icons.manage_accounts_rounded), title: l10n.lookingForAccountSpecificFeedSettings, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccount), highlightKey: settingToHighlightKey, highlighted: false), @@ -444,10 +441,12 @@ class _GeneralSettingsPageState extends State with SingleTi ThunderListOption( title: l10n.appLanguage, bottomSheetHeading: Align(alignment: Alignment.centerLeft, child: Text(l10n.translationsMayNotBeComplete)), - value: ListPickerItem(label: LanguageLocal.getDisplayLanguage(currentLocale.languageCode, currentLocale.toLanguageTag()), icon: Icons.language_rounded, payload: currentLocale), - options: supportedLocales.map((e) => ListPickerItem(label: LanguageLocal.getDisplayLanguage(e.languageCode, e.toLanguageTag()), icon: Icons.language_rounded, payload: e)).toList(), + value: + ThunderListPickerItem(label: LanguageLocal.getDisplayLanguage(currentLocale.languageCode, currentLocale.toLanguageTag()), icon: Icons.language_rounded, payload: currentLocale), + options: + supportedLocales.map((e) => ThunderListPickerItem(label: LanguageLocal.getDisplayLanguage(e.languageCode, e.toLanguageTag()), icon: Icons.language_rounded, payload: e)).toList(), leading: Icon(Icons.language_rounded), - onChanged: (ListPickerItem value) async { + onChanged: (ThunderListPickerItem value) async { setPreferences(LocalSettings.appLanguageCode, value.payload); }, valueDisplay: Row( @@ -550,7 +549,7 @@ class _GeneralSettingsPageState extends State with SingleTi ), ThunderListOption( title: l10n.defaultCommentSortType, - value: ListPickerItem(label: defaultCommentSortType.name, icon: Icons.local_fire_department_rounded, payload: defaultCommentSortType), + value: ThunderListPickerItem(label: defaultCommentSortType.name, icon: Icons.local_fire_department_rounded, payload: defaultCommentSortType), options: getCommentSortTypeItems(), leading: Icon(Icons.comment_bank_rounded), onChanged: (_) async {}, @@ -609,7 +608,7 @@ class _GeneralSettingsPageState extends State with SingleTi ), ThunderListOption( title: l10n.browserMode, - value: ListPickerItem( + value: ThunderListPickerItem( label: switch (browserMode) { BrowserMode.inApp => l10n.linkHandlingInAppShort, BrowserMode.customTabs => l10n.linkHandlingCustomTabsShort, @@ -619,9 +618,9 @@ class _GeneralSettingsPageState extends State with SingleTi capitalizeLabel: false, ), options: [ - ListPickerItem(label: l10n.linkHandlingInApp, icon: Icons.dataset_linked_rounded, payload: BrowserMode.inApp), - ListPickerItem(label: l10n.linkHandlingCustomTabs, icon: Icons.language_rounded, payload: BrowserMode.customTabs), - ListPickerItem(label: l10n.linkHandlingExternal, icon: Icons.open_in_browser_rounded, payload: BrowserMode.external), + ThunderListPickerItem(label: l10n.linkHandlingInApp, icon: Icons.dataset_linked_rounded, payload: BrowserMode.inApp), + ThunderListPickerItem(label: l10n.linkHandlingCustomTabs, icon: Icons.language_rounded, payload: BrowserMode.customTabs), + ThunderListPickerItem(label: l10n.linkHandlingExternal, icon: Icons.open_in_browser_rounded, payload: BrowserMode.external), ], leading: Icon(Icons.link_rounded), onChanged: (value) => setPreferences(LocalSettings.browserMode, value.payload.name), @@ -641,10 +640,7 @@ class _GeneralSettingsPageState extends State with SingleTi if (!kIsWeb && Platform.isAndroid) ThunderSettingsTile( leading: Icon(Icons.add_link), - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { try { const AndroidIntent intent = AndroidIntent( @@ -671,7 +667,7 @@ class _GeneralSettingsPageState extends State with SingleTi if (!kIsWeb && Platform.isAndroid) ThunderListOption( title: l10n.imageCachingMode, - value: ListPickerItem( + value: ThunderListPickerItem( label: switch (imageCachingMode) { ImageCachingMode.aggressive => l10n.imageCachingModeAggressiveShort, ImageCachingMode.relaxed => l10n.imageCachingModeRelaxedShort, @@ -680,8 +676,8 @@ class _GeneralSettingsPageState extends State with SingleTi capitalizeLabel: false, ), options: [ - ListPickerItem(icon: Icons.broken_image, label: l10n.imageCachingModeAggressive, payload: ImageCachingMode.aggressive, capitalizeLabel: false), - ListPickerItem(icon: Icons.broken_image_outlined, label: l10n.imageCachingModeRelaxed, payload: ImageCachingMode.relaxed, capitalizeLabel: false), + ThunderListPickerItem(icon: Icons.broken_image, label: l10n.imageCachingModeAggressive, payload: ImageCachingMode.aggressive, capitalizeLabel: false), + ThunderListPickerItem(icon: Icons.broken_image_outlined, label: l10n.imageCachingModeRelaxed, payload: ImageCachingMode.relaxed, capitalizeLabel: false), ], leading: Icon(switch (imageCachingMode) { ImageCachingMode.aggressive => Icons.broken_image, @@ -793,7 +789,7 @@ class _GeneralSettingsPageState extends State with SingleTi ], ), ), - value: const ListPickerItem(payload: -1), + value: const ThunderListPickerItem(payload: -1), options: const [], disabled: accounts.isEmpty, leading: Icon(inboxNotificationType == NotificationType.none ? Icons.notifications_off_rounded : Icons.notifications_on_rounded), @@ -802,7 +798,7 @@ class _GeneralSettingsPageState extends State with SingleTi highlighted: settingToHighlight == LocalSettings.inboxNotificationType, customListPicker: StatefulBuilder( builder: (context, setState) { - return BottomSheetListPicker( + return ThunderBottomSheetListPicker( title: l10n.pushNotification, heading: Align( alignment: Alignment.centerLeft, @@ -811,13 +807,13 @@ class _GeneralSettingsPageState extends State with SingleTi previouslySelected: inboxNotificationType, items: Platform.isAndroid ? [ - ListPickerItem( + ThunderListPickerItem( icon: Icons.notifications_off_rounded, label: l10n.none, payload: NotificationType.none, softWrap: true, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.notifications_rounded, label: l10n.useLocalNotifications, subtitle: l10n.useLocalNotificationsDescription, @@ -825,7 +821,7 @@ class _GeneralSettingsPageState extends State with SingleTi softWrap: true, ), if (enableExperimentalFeatures) - ListPickerItem( + ThunderListPickerItem( icon: Icons.notifications_active_rounded, label: l10n.useUnifiedPushNotifications, subtitleWidget: Text.rich( @@ -854,14 +850,14 @@ class _GeneralSettingsPageState extends State with SingleTi ), ] : [ - ListPickerItem( + ThunderListPickerItem( icon: Icons.notifications_off_rounded, label: l10n.disablePushNotifications, payload: NotificationType.none, softWrap: true, ), if (enableExperimentalFeatures) - ListPickerItem( + ThunderListPickerItem( icon: Icons.notifications_active_rounded, label: l10n.useApplePushNotifications, subtitle: l10n.useApplePushNotificationsDescription, @@ -869,7 +865,7 @@ class _GeneralSettingsPageState extends State with SingleTi softWrap: true, ), ], - onSelect: (ListPickerItem notificationType) async { + onSelect: (ThunderListPickerItem notificationType) async { if (notificationType.payload == inboxNotificationType) { return; } @@ -893,7 +889,7 @@ class _GeneralSettingsPageState extends State with SingleTi ); if (!success) { - showSnackbar(l10n.failedToUpdateNotificationSettings); + showThunderSnackbar(l10n.failedToUpdateNotificationSettings); } _initPreferences(); }, @@ -905,10 +901,7 @@ class _GeneralSettingsPageState extends State with SingleTi leading: Icon(Icons.electrical_services_rounded), title: l10n.pushNotificationServer, subtitle: pushNotificationServer, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { showThunderDialog( context: context, @@ -949,10 +942,7 @@ class _GeneralSettingsPageState extends State with SingleTi ThunderSettingsTile( leading: Icon(Icons.bug_report_rounded), title: l10n.havingIssuesWithNotifications, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageDebug), highlightKey: settingToHighlightKey, highlighted: false), @@ -966,17 +956,14 @@ class _GeneralSettingsPageState extends State with SingleTi leading: Icon(Icons.settings_rounded), title: l10n.saveSettings, subtitle: l10n.exportSettingsSubtitle, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { String? savedFilePath = await UserPreferences.exportToJson(); if (savedFilePath?.isNotEmpty == true) { - showSnackbar(l10n.settingsExportedSuccessfully(savedFilePath!)); + showThunderSnackbar(l10n.settingsExportedSuccessfully(savedFilePath!)); } else { - showSnackbar(l10n.settingsNotExportedSuccessfully); + showThunderSnackbar(l10n.settingsNotExportedSuccessfully); } }, highlightKey: settingToHighlightKey, @@ -985,22 +972,19 @@ class _GeneralSettingsPageState extends State with SingleTi ThunderSettingsTile( leading: Icon(Icons.import_export_rounded), title: l10n.importSettings, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { bool? importedSuccessfully = await UserPreferences.importFromJson(); if (importedSuccessfully == true) { - showSnackbar(l10n.settingsImportedSuccessfully); + showThunderSnackbar(l10n.settingsImportedSuccessfully); if (context.mounted) { _initPreferences(); context.read().reload(); context.read().reload(); } else { - showSnackbar(l10n.settingsNotImportedSuccessfully); + showThunderSnackbar(l10n.settingsNotImportedSuccessfully); } } }, @@ -1010,10 +994,7 @@ class _GeneralSettingsPageState extends State with SingleTi leading: Icon(Icons.dashboard_customize_rounded), title: l10n.exportDatabase, subtitle: l10n.exportDatabaseSubtitle, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { bool result = false; @@ -1035,9 +1016,9 @@ class _GeneralSettingsPageState extends State with SingleTi String? savedFilePath = await exportDatabase(); if (savedFilePath?.isNotEmpty == true) { - showSnackbar(l10n.databaseExportedSuccessfully(savedFilePath!)); + showThunderSnackbar(l10n.databaseExportedSuccessfully(savedFilePath!)); } else { - showSnackbar(l10n.databaseNotExportedSuccessfully); + showThunderSnackbar(l10n.databaseNotExportedSuccessfully); } }, highlightKey: settingToHighlightKey, @@ -1046,17 +1027,14 @@ class _GeneralSettingsPageState extends State with SingleTi ThunderSettingsTile( leading: Icon(Icons.dashboard_customize_outlined), title: l10n.importDatabase, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { bool importedSuccessfully = await importDatabase(); if (importedSuccessfully == true) { - showSnackbar(l10n.databaseImportedSuccessfully); + showThunderSnackbar(l10n.databaseImportedSuccessfully); } else { - showSnackbar(l10n.databaseNotImportedSuccessfully); + showThunderSnackbar(l10n.databaseNotImportedSuccessfully); } }, highlightKey: settingToHighlightKey, diff --git a/lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart index 980a19274..666bb5f87 100644 --- a/lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart @@ -52,21 +52,21 @@ class _GestureSettingsPageState extends State with TickerPr bool isLoading = true; /// The available gesture options - List> postGestureOptions = [ - ListPickerItem(icon: SwipeAction.upvote.getIcon(), label: SwipeAction.upvote.label, payload: SwipeAction.upvote), - ListPickerItem(icon: SwipeAction.downvote.getIcon(), label: SwipeAction.downvote.label, payload: SwipeAction.downvote), - ListPickerItem(icon: SwipeAction.save.getIcon(), label: SwipeAction.save.label, payload: SwipeAction.save), - ListPickerItem(icon: SwipeAction.toggleRead.getIcon(), label: SwipeAction.toggleRead.label, payload: SwipeAction.toggleRead), - ListPickerItem(icon: SwipeAction.hide.getIcon(), label: SwipeAction.hide.label, payload: SwipeAction.hide), - ListPickerItem(icon: SwipeAction.none.getIcon(), label: SwipeAction.none.label, payload: SwipeAction.none), + List> postGestureOptions = [ + ThunderListPickerItem(icon: SwipeAction.upvote.getIcon(), label: SwipeAction.upvote.label, payload: SwipeAction.upvote), + ThunderListPickerItem(icon: SwipeAction.downvote.getIcon(), label: SwipeAction.downvote.label, payload: SwipeAction.downvote), + ThunderListPickerItem(icon: SwipeAction.save.getIcon(), label: SwipeAction.save.label, payload: SwipeAction.save), + ThunderListPickerItem(icon: SwipeAction.toggleRead.getIcon(), label: SwipeAction.toggleRead.label, payload: SwipeAction.toggleRead), + ThunderListPickerItem(icon: SwipeAction.hide.getIcon(), label: SwipeAction.hide.label, payload: SwipeAction.hide), + ThunderListPickerItem(icon: SwipeAction.none.getIcon(), label: SwipeAction.none.label, payload: SwipeAction.none), ]; - List> commentGestureOptions = [ - ListPickerItem(icon: SwipeAction.upvote.getIcon(), label: SwipeAction.upvote.label, payload: SwipeAction.upvote), - ListPickerItem(icon: SwipeAction.downvote.getIcon(), label: SwipeAction.downvote.label, payload: SwipeAction.downvote), - ListPickerItem(icon: SwipeAction.save.getIcon(), label: SwipeAction.save.label, payload: SwipeAction.save), - ListPickerItem(icon: SwipeAction.reply.getIcon(), label: SwipeAction.reply.label, payload: SwipeAction.reply), - ListPickerItem(icon: SwipeAction.none.getIcon(), label: SwipeAction.none.label, payload: SwipeAction.none), + List> commentGestureOptions = [ + ThunderListPickerItem(icon: SwipeAction.upvote.getIcon(), label: SwipeAction.upvote.label, payload: SwipeAction.upvote), + ThunderListPickerItem(icon: SwipeAction.downvote.getIcon(), label: SwipeAction.downvote.label, payload: SwipeAction.downvote), + ThunderListPickerItem(icon: SwipeAction.save.getIcon(), label: SwipeAction.save.label, payload: SwipeAction.save), + ThunderListPickerItem(icon: SwipeAction.reply.getIcon(), label: SwipeAction.reply.label, payload: SwipeAction.reply), + ThunderListPickerItem(icon: SwipeAction.none.getIcon(), label: SwipeAction.none.label, payload: SwipeAction.none), ]; GlobalKey settingToHighlightKey = GlobalKey(); @@ -312,13 +312,13 @@ class _GestureSettingsPageState extends State with TickerPr ThunderListOption( title: l10n.imagePeekDuration, subtitle: l10n.imagePeekDurationDescription, - value: ListPickerItem(label: '${imagePeekDuration}ms', icon: Icons.touch_app_rounded, payload: imagePeekDuration), + value: ThunderListPickerItem(label: '${imagePeekDuration}ms', icon: Icons.touch_app_rounded, payload: imagePeekDuration), options: [ - ListPickerItem(icon: Icons.touch_app_rounded, label: '100ms', payload: 100), - ListPickerItem(icon: Icons.touch_app_rounded, label: '200ms', payload: 200), - ListPickerItem(icon: Icons.touch_app_rounded, label: '300ms', payload: 300), - ListPickerItem(icon: Icons.touch_app_rounded, label: '400ms', payload: 400), - ListPickerItem(icon: Icons.touch_app_rounded, label: '500ms', payload: 500), + ThunderListPickerItem(icon: Icons.touch_app_rounded, label: '100ms', payload: 100), + ThunderListPickerItem(icon: Icons.touch_app_rounded, label: '200ms', payload: 200), + ThunderListPickerItem(icon: Icons.touch_app_rounded, label: '300ms', payload: 300), + ThunderListPickerItem(icon: Icons.touch_app_rounded, label: '400ms', payload: 400), + ThunderListPickerItem(icon: Icons.touch_app_rounded, label: '500ms', payload: 500), ], leading: Icon(Icons.touch_app_rounded), onChanged: (value) async => setPreferences(LocalSettings.imagePeekDuration, value.payload), @@ -541,10 +541,7 @@ class _GestureSettingsPageState extends State with TickerPr ThunderSettingsTile( leading: Icon(Icons.color_lens_rounded), title: l10n.actionColorsRedirect, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage(context, LocalSettings.actionColors), highlightKey: settingToHighlightKey, highlighted: false), diff --git a/lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart b/lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart index 2f8f8ab2b..9c57cf545 100644 --- a/lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart +++ b/lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart @@ -164,7 +164,7 @@ class _VideoPlayerSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.videoAutoLoop), ThunderListOption( title: l10n.videoAutoPlay, - value: ListPickerItem( + value: ThunderListPickerItem( label: switch (videoAutoPlay) { VideoAutoPlay.never => l10n.never, VideoAutoPlay.always => l10n.always, @@ -173,9 +173,9 @@ class _VideoPlayerSettingsPageState extends State { icon: Icons.video_settings_outlined, payload: videoAutoPlay), options: [ - ListPickerItem(icon: Icons.not_interested, label: l10n.never, payload: VideoAutoPlay.never), - ListPickerItem(icon: Icons.play_arrow, label: l10n.always, payload: VideoAutoPlay.always), - ListPickerItem(icon: Icons.wifi, label: l10n.onWifi, payload: VideoAutoPlay.onWifi), + ThunderListPickerItem(icon: Icons.not_interested, label: l10n.never, payload: VideoAutoPlay.never), + ThunderListPickerItem(icon: Icons.play_arrow, label: l10n.always, payload: VideoAutoPlay.always), + ThunderListPickerItem(icon: Icons.wifi, label: l10n.onWifi, payload: VideoAutoPlay.onWifi), ], leading: Icon(Icons.play_circle), onChanged: (value) async => setPreferences(LocalSettings.videoAutoPlay, value.payload.name), @@ -184,16 +184,16 @@ class _VideoPlayerSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.videoAutoPlay), ThunderListOption( title: l10n.videoDefaultPlaybackSpeed, - value: ListPickerItem(label: videoDefaultPlaybackSpeed.label, icon: Icons.speed, payload: videoDefaultPlaybackSpeed), + value: ThunderListPickerItem(label: videoDefaultPlaybackSpeed.label, icon: Icons.speed, payload: videoDefaultPlaybackSpeed), options: [ - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.pointTow5x.label, payload: VideoPlayBackSpeed.pointTow5x), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.point5x.label, payload: VideoPlayBackSpeed.point5x), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.pointSeven5x.label, payload: VideoPlayBackSpeed.pointSeven5x), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.normal.label, payload: VideoPlayBackSpeed.normal), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.onePointTwo5x.label, payload: VideoPlayBackSpeed.onePointTwo5x), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.onePoint5x.label, payload: VideoPlayBackSpeed.onePoint5x), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.onePointSeven5x.label, payload: VideoPlayBackSpeed.onePointSeven5x), - ListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.twoX.label, payload: VideoPlayBackSpeed.twoX), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.pointTow5x.label, payload: VideoPlayBackSpeed.pointTow5x), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.point5x.label, payload: VideoPlayBackSpeed.point5x), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.pointSeven5x.label, payload: VideoPlayBackSpeed.pointSeven5x), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.normal.label, payload: VideoPlayBackSpeed.normal), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.onePointTwo5x.label, payload: VideoPlayBackSpeed.onePointTwo5x), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.onePoint5x.label, payload: VideoPlayBackSpeed.onePoint5x), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.onePointSeven5x.label, payload: VideoPlayBackSpeed.onePointSeven5x), + ThunderListPickerItem(icon: Icons.speed, label: VideoPlayBackSpeed.twoX.label, payload: VideoPlayBackSpeed.twoX), ], leading: Icon(Icons.speed), onChanged: (value) async => setPreferences(LocalSettings.videoDefaultPlaybackSpeed, value.payload.name), @@ -202,7 +202,7 @@ class _VideoPlayerSettingsPageState extends State { highlighted: settingToHighlight == LocalSettings.videoDefaultPlaybackSpeed), ThunderListOption( title: l10n.videoPlayerMode, - value: ListPickerItem( + value: ThunderListPickerItem( label: switch (videoPlayerMode) { VideoPlayerMode.inApp => l10n.videoPlayerInApp, VideoPlayerMode.customTabs => l10n.linkHandlingCustomTabsShort, @@ -212,9 +212,9 @@ class _VideoPlayerSettingsPageState extends State { capitalizeLabel: false, ), options: [ - ListPickerItem(label: l10n.videoPlayerInApp, icon: Icons.play_circle_fill, payload: VideoPlayerMode.inApp), - ListPickerItem(label: l10n.linkHandlingCustomTabs, icon: Icons.language_rounded, payload: VideoPlayerMode.customTabs), - ListPickerItem(label: l10n.videoLinkHandlingExternal, icon: Icons.open_in_browser_rounded, payload: VideoPlayerMode.externalPlayer), + ThunderListPickerItem(label: l10n.videoPlayerInApp, icon: Icons.play_circle_fill, payload: VideoPlayerMode.inApp), + ThunderListPickerItem(label: l10n.linkHandlingCustomTabs, icon: Icons.language_rounded, payload: VideoPlayerMode.customTabs), + ThunderListPickerItem(label: l10n.videoLinkHandlingExternal, icon: Icons.open_in_browser_rounded, payload: VideoPlayerMode.externalPlayer), ], leading: Icon(Icons.video_label_outlined), onChanged: (value) => setPreferences(LocalSettings.videoPlayerMode, value.payload.name), diff --git a/lib/src/features/settings/presentation/pages/debug_settings_page.dart b/lib/src/features/settings/presentation/pages/debug_settings_page.dart index 16e26a84e..304eb4316 100644 --- a/lib/src/features/settings/presentation/pages/debug_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/debug_settings_page.dart @@ -170,10 +170,7 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.co_present_rounded), title: l10n.deleteLocalPreferences, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { showThunderDialog( context: context, @@ -186,9 +183,9 @@ class _DebugSettingsPageState extends State { if (cleared) { context.read().reload(); - showSnackbar(AppLocalizations.of(context)!.clearedUserPreferences); + showThunderSnackbar(AppLocalizations.of(context)!.clearedUserPreferences); } else { - showSnackbar(AppLocalizations.of(context)!.failedToPerformAction); + showThunderSnackbar(AppLocalizations.of(context)!.failedToPerformAction); } Navigator.of(dialogContext).pop(); @@ -203,10 +200,7 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.data_array_rounded), title: l10n.deleteLocalDatabase, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { showThunderDialog( context: context, @@ -221,7 +215,7 @@ class _DebugSettingsPageState extends State { await databaseFactory.deleteDatabase(file.path); if (context.mounted) { - showSnackbar(AppLocalizations.of(context)!.clearedDatabase); + showThunderSnackbar(AppLocalizations.of(context)!.clearedDatabase); Navigator.of(context).pop(); } }, @@ -239,13 +233,10 @@ class _DebugSettingsPageState extends State { return ThunderSettingsTile( leading: Icon(Icons.data_saver_off_rounded), title: l10n.clearCache('${(snapshot.data! / (1024 * 1024)).toStringAsFixed(2)} MB'), - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () async { await clearDiskCachedImages(); - if (context.mounted) showSnackbar(l10n.clearedCache); + if (context.mounted) showThunderSnackbar(l10n.clearedCache); setState(() {}); // Trigger a rebuild to refresh the cache size }, highlightKey: settingToHighlightKey, @@ -330,10 +321,7 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.notifications_rounded), title: l10n.sendTestLocalNotification, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: inboxNotificationType == NotificationType.local ? () { showTestAndroidNotification(); @@ -346,10 +334,7 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.circle_notifications_rounded), title: l10n.sendBackgroundTestLocalNotification, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: inboxNotificationType == NotificationType.local ? () async { bool result = false; @@ -393,18 +378,15 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.notifications_rounded), title: l10n.sendTestUnifiedPushNotification, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: inboxNotificationType == NotificationType.unifiedPush ? () async { final error = await requestTestNotification(resolveActiveAccount(context)); if (error == null) { - showSnackbar(l10n.sentRequestForTestNotification); + showThunderSnackbar(l10n.sentRequestForTestNotification); } else { - showSnackbar(l10n.failedToCommunicateWithThunderNotificationServer('$pushNotificationServer\n\n$error')); + showThunderSnackbar(l10n.failedToCommunicateWithThunderNotificationServer('$pushNotificationServer\n\n$error')); } } : null, @@ -415,10 +397,7 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.circle_notifications_rounded), title: l10n.sendBackgroundTestUnifiedPushNotification, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: inboxNotificationType == NotificationType.unifiedPush ? () async { bool result = false; @@ -441,9 +420,9 @@ class _DebugSettingsPageState extends State { final error = await requestTestNotification(resolveActiveAccount(context)); if (error == null) { - showSnackbar(l10n.sentRequestForTestNotification); + showThunderSnackbar(l10n.sentRequestForTestNotification); } else { - showSnackbar(l10n.failedToCommunicateWithThunderNotificationServer('$pushNotificationServer\n\n$error')); + showThunderSnackbar(l10n.failedToCommunicateWithThunderNotificationServer('$pushNotificationServer\n\n$error')); } SystemNavigator.pop(); @@ -459,10 +438,7 @@ class _DebugSettingsPageState extends State { ThunderSettingsTile( leading: Icon(Icons.edit_notifications_rounded), title: l10n.changeNotificationSettings, - trailing: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage(context, LocalSettings.inboxNotificationType), highlightKey: settingToHighlightKey, highlighted: false), @@ -499,8 +475,8 @@ class _DebugSettingsPageState extends State { SizedBox(height: 8.0), ThunderListOption( title: l10n.imageDimensionTimeout, - value: ListPickerItem(label: '${imageDimensionTimeout}s', icon: Icons.timelapse, payload: imageDimensionTimeout), - options: imageDimensionTimeouts.map((value) => ListPickerItem(icon: Icons.timelapse, label: '${value}s', payload: value)).toList(), + value: ThunderListPickerItem(label: '${imageDimensionTimeout}s', icon: Icons.timelapse, payload: imageDimensionTimeout), + options: imageDimensionTimeouts.map((value) => ThunderListPickerItem(icon: Icons.timelapse, label: '${value}s', payload: value)).toList(), leading: Icon(Icons.timelapse), onChanged: (value) async => setPreferences(LocalSettings.imageDimensionTimeout, value.payload), highlightKey: settingToHighlightKey, diff --git a/lib/src/features/settings/presentation/pages/drafts_settings_page.dart b/lib/src/features/settings/presentation/pages/drafts_settings_page.dart index 3f3f57cdf..2c1d8b2c9 100644 --- a/lib/src/features/settings/presentation/pages/drafts_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/drafts_settings_page.dart @@ -316,7 +316,7 @@ class _DraftsSettingsPageState extends State with SingleTick if (opened != DraftOpenResult.opened) { await _draftRepository.clearActiveDraft(); - if (mounted) showSnackbar(GlobalContext.l10n.unexpectedError); + if (mounted) showThunderSnackbar(GlobalContext.l10n.unexpectedError); } if (mounted) await _loadDrafts(); diff --git a/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart b/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart index 9ab99be34..19345e326 100644 --- a/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart @@ -15,7 +15,7 @@ import 'package:thunder/src/shared/name/full_name_widgets.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; class UserLabelSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/utils/setting_link_utils.dart b/lib/src/features/settings/presentation/utils/setting_link_utils.dart index b60c6f184..9dc439589 100644 --- a/lib/src/features/settings/presentation/utils/setting_link_utils.dart +++ b/lib/src/features/settings/presentation/utils/setting_link_utils.dart @@ -4,7 +4,7 @@ import 'package:flutter/services.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Generates a link to a local setting. /// @@ -16,7 +16,7 @@ void shareSetting(BuildContext context, LocalSettings? setting, String descripti final path = '${l10n.getLocalSettingLocalization(setting.category.toString())} > ${l10n.getLocalSettingLocalization(setting.subCategory.toString())} > $description'; Clipboard.setData(ClipboardData(text: '[Thunder Setting: $path](thunder://setting-${setting.name})')); - showSnackbar('Setting link copied to clipboard!'); + showThunderSnackbar('Setting link copied to clipboard!'); } void shareLocalSetting(BuildContext context, LocalSettings setting) { diff --git a/lib/src/features/settings/presentation/widgets/accessibility_profile.dart b/lib/src/features/settings/presentation/widgets/accessibility_profile.dart index 5ff1fe564..76f7e3154 100644 --- a/lib/src/features/settings/presentation/widgets/accessibility_profile.dart +++ b/lib/src/features/settings/presentation/widgets/accessibility_profile.dart @@ -31,7 +31,7 @@ class SettingProfile extends StatelessWidget { bool recentSuccess = false; return ThunderExpandableOption( - icon: Icon(icon), + leading: Icon(icon), title: name, child: Column( children: [ @@ -68,12 +68,12 @@ class SettingProfile extends StatelessWidget { // before adding a profile containing those types. success = false; if (context.mounted) { - showSnackbar(AppLocalizations.of(context)!.settingTypeNotSupported(entry.value.runtimeType)); + showThunderSnackbar(AppLocalizations.of(context)!.settingTypeNotSupported(entry.value.runtimeType)); } } } if (context.mounted && success) { - showSnackbar(AppLocalizations.of(context)!.profileAppliedSuccessfully(name)); + showThunderSnackbar(AppLocalizations.of(context)!.profileAppliedSuccessfully(name)); setState(() => recentSuccess = true); Future.delayed(const Duration(seconds: 5), () async { setState(() => recentSuccess = false); diff --git a/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart b/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart index c508b47df..f9b1b066f 100644 --- a/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart +++ b/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart @@ -53,7 +53,7 @@ class ActionColorSettingWidget extends StatelessWidget { ), ThunderListOption( isBottomModalScrollControlled: true, - value: const ListPickerItem(payload: -1), + value: const ThunderListPickerItem(payload: -1), options: const [], title: l10n.actionColors, leading: Icon(Icons.color_lens_rounded), @@ -62,10 +62,10 @@ class ActionColorSettingWidget extends StatelessWidget { highlighted: settingToHighlight == LocalSettings.actionColors, customListPicker: StatefulBuilder( builder: (context, setState) { - return BottomSheetListPicker( + return ThunderBottomSheetListPicker( title: l10n.actionColors, items: [ - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -108,7 +108,7 @@ class ActionColorSettingWidget extends StatelessWidget { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -151,7 +151,7 @@ class ActionColorSettingWidget extends StatelessWidget { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -194,7 +194,7 @@ class ActionColorSettingWidget extends StatelessWidget { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -237,7 +237,7 @@ class ActionColorSettingWidget extends StatelessWidget { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( @@ -280,7 +280,7 @@ class ActionColorSettingWidget extends StatelessWidget { ), ), ), - ListPickerItem( + ThunderListPickerItem( payload: -1, customWidget: ListTile( title: Text( diff --git a/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart b/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart index e983d45dc..2f2684000 100644 --- a/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart +++ b/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/config.dart'; -import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; class DiscussionLanguageSelector extends StatefulWidget { const DiscussionLanguageSelector({super.key}); diff --git a/lib/src/features/settings/presentation/widgets/post_placeholder.dart b/lib/src/features/settings/presentation/widgets/post_placeholder.dart deleted file mode 100644 index 6ee1ade86..000000000 --- a/lib/src/features/settings/presentation/widgets/post_placeholder.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; - -class PostPlaceholder extends StatelessWidget { - const PostPlaceholder({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 0, 2), - child: Container( - width: 100, - height: 10, - decoration: BoxDecoration( - color: theme.hintColor.withValues(alpha: .25), - borderRadius: const BorderRadius.all( - Radius.elliptical(5, 5), - ), - ), - ), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 2, 0, 2), - child: Container( - width: 75, - height: 10, - decoration: BoxDecoration( - color: theme.hintColor.withValues(alpha: .1), - borderRadius: const BorderRadius.all( - Radius.elliptical(5, 5), - ), - ), - ), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 2, 0, 10), - child: Container( - width: 75, - height: 10, - decoration: BoxDecoration( - color: theme.hintColor.withValues(alpha: .1), - borderRadius: const BorderRadius.all( - Radius.elliptical(5, 5), - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/src/features/settings/presentation/widgets/swipe_picker.dart b/lib/src/features/settings/presentation/widgets/swipe_picker.dart index cb14f29f0..76901c7a9 100644 --- a/lib/src/features/settings/presentation/widgets/swipe_picker.dart +++ b/lib/src/features/settings/presentation/widgets/swipe_picker.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; +import 'package:thunder/packages/ui/ui.dart'; enum SwipePickerSide { left, right } class SwipePickerItem { String label; - List> options; + List> options; SwipeAction value; - final void Function(ListPickerItem) onChanged; + final void Function(ThunderListPickerItem) onChanged; SwipePickerItem({ required this.label, @@ -57,7 +57,7 @@ class SwipePicker extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (context) => BottomSheetListPicker( + builder: (context) => ThunderBottomSheetListPicker( title: items[0].label, items: items[0].options, onSelect: (value) async { @@ -99,7 +99,7 @@ class SwipePicker extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (context) => BottomSheetListPicker( + builder: (context) => ThunderBottomSheetListPicker( title: items[1].label, items: items[1].options, onSelect: (value) async { @@ -134,7 +134,7 @@ class SwipePicker extends StatelessWidget { child: Container( height: 65, decoration: const BoxDecoration(), - child: const PostPlaceholder(), + child: const ThunderSkeletonPlaceholder.post(), ), ), if (side == SwipePickerSide.right && items.length >= 2) @@ -148,7 +148,7 @@ class SwipePicker extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (context) => BottomSheetListPicker( + builder: (context) => ThunderBottomSheetListPicker( title: items[1].label, items: items[1].options, onSelect: (value) async { @@ -198,7 +198,7 @@ class SwipePicker extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, - builder: (context) => BottomSheetListPicker( + builder: (context) => ThunderBottomSheetListPicker( title: items[0].label, items: items[0].options, onSelect: (value) async { diff --git a/lib/src/features/settings/presentation/widgets/widgets.dart b/lib/src/features/settings/presentation/widgets/widgets.dart index 9d85361a0..4f0ec6b9d 100644 --- a/lib/src/features/settings/presentation/widgets/widgets.dart +++ b/lib/src/features/settings/presentation/widgets/widgets.dart @@ -1,5 +1,4 @@ export 'accessibility_profile.dart'; export 'action_color_setting_widget.dart'; export 'discussion_language_selector.dart'; -export 'post_placeholder.dart'; export 'swipe_picker.dart'; diff --git a/lib/src/features/user/presentation/pages/lemmy_user_settings_page.dart b/lib/src/features/user/presentation/pages/lemmy_user_settings_page.dart index f67fdaa5e..9e71064cb 100644 --- a/lib/src/features/user/presentation/pages/lemmy_user_settings_page.dart +++ b/lib/src/features/user/presentation/pages/lemmy_user_settings_page.dart @@ -75,19 +75,19 @@ class _LemmyUserSettingsPageState extends State { super.dispose(); } - List> _feedTypeOptions() { + List> _feedTypeOptions() { return const [ - ListPickerItem( + ThunderListPickerItem( icon: Icons.view_list_rounded, label: 'Subscribed', payload: FeedListType.subscribed, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.home_rounded, label: 'All', payload: FeedListType.all, ), - ListPickerItem( + ThunderListPickerItem( icon: Icons.grid_view_rounded, label: 'Local', payload: FeedListType.local, @@ -95,20 +95,20 @@ class _LemmyUserSettingsPageState extends State { ]; } - ListPickerItem _currentFeedTypeOption( + ThunderListPickerItem _currentFeedTypeOption( FeedListType? currentType, ) { return _feedTypeOptions().firstWhereOrNull( (item) => item.payload == currentType, ) ?? - const ListPickerItem( + const ThunderListPickerItem( icon: Icons.view_list_rounded, label: 'Subscribed', payload: FeedListType.subscribed, ); } - ListPickerItem _currentSortOption( + ThunderListPickerItem _currentSortOption( Account account, PostSortType? currentSortType, ) { @@ -122,7 +122,7 @@ class _LemmyUserSettingsPageState extends State { return options.firstWhereOrNull((item) => item.payload == currentSortType) ?? allPostSortTypeItems.firstWhere( (item) => item.payload == currentSortType, - orElse: () => ListPickerItem( + orElse: () => ThunderListPickerItem( payload: currentSortType, icon: Icons.sort_rounded, label: currentSortType.value, @@ -147,7 +147,7 @@ class _LemmyUserSettingsPageState extends State { final currentSortOption = _currentSortOption(account, localUser?.defaultSortType); return [ - UserSettingsSectionHeader(title: l10n.general), + ThunderSectionHeader(title: l10n.general), ThunderSettingsTile( leading: const Icon(Icons.person_rounded), title: l10n.displayName, @@ -169,7 +169,7 @@ class _LemmyUserSettingsPageState extends State { onLongPress: () => shareLocalSetting(context, LocalSettings.accountProfileBio), highlighted: settingToHighlight == LocalSettings.accountProfileBio, ), - UserSettingsSectionHeader( + ThunderSectionHeader( title: l10n.feedSettings, description: l10n.settingOverrideLabel, ), @@ -239,9 +239,8 @@ class _LemmyUserSettingsPageState extends State { ThunderToggleOption( title: l10n.showBotAccounts, value: localUser?.showBotAccounts, - iconEnabled: Thunder.robot, - iconDisabled: Thunder.robot, - iconSpacing: 14.0, + iconEnabled: ThunderIcon.robot, + iconDisabled: ThunderIcon.robot, onChanged: (value) => context.read().updateSettings(showBotAccounts: value), disabled: isUpdating, highlightKey: settingToHighlightKey, @@ -265,11 +264,11 @@ class _LemmyUserSettingsPageState extends State { ), highlighted: settingToHighlight == LocalSettings.accountDefaultFeedType, ), - UserSettingsSectionHeader(title: l10n.contentManagement), + ThunderSectionHeader(title: l10n.contentManagement), ThunderSettingsTile( leading: const Icon(Icons.language_rounded), title: l10n.discussionLanguages, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: isUpdating ? null : () => navigateToSettingPage( @@ -283,7 +282,7 @@ class _LemmyUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.block_rounded), title: l10n.blockSettingLabel, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage( context, LocalSettings.settingsPageAccountBlocks, @@ -292,14 +291,14 @@ class _LemmyUserSettingsPageState extends State { onLongPress: () => shareLocalSetting(context, LocalSettings.accountBlocks), highlighted: settingToHighlight == LocalSettings.accountBlocks, ), - UserSettingsSectionHeader( + ThunderSectionHeader( title: l10n.importExportSettings, description: l10n.importExportLemmyAccountSettingsSubtitle, ), ThunderSettingsTile( leading: const Icon(Icons.file_download_rounded), title: l10n.exportLemmyAccountSettingsDescription, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => _exportSettings(context), highlightKey: settingToHighlightKey, onLongPress: () => shareLocalSetting( @@ -311,7 +310,7 @@ class _LemmyUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.file_upload_rounded), title: l10n.importLemmyAccountSettingsDescription, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => _importSettings(context), highlightKey: settingToHighlightKey, onLongPress: () => shareLocalSetting( @@ -320,11 +319,11 @@ class _LemmyUserSettingsPageState extends State { ), highlighted: settingToHighlight == LocalSettings.accountImportSettings, ), - UserSettingsSectionHeader(title: l10n.dangerZone), + ThunderSectionHeader(title: l10n.dangerZone), ThunderSettingsTile( leading: const Icon(Icons.password), title: l10n.changePassword, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => _openInstanceSettings( context, title: l10n.changePassword, @@ -340,7 +339,7 @@ class _LemmyUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.delete_forever_rounded), title: l10n.deleteAccount, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => _openInstanceSettings( context, title: l10n.deleteAccount, @@ -356,7 +355,7 @@ class _LemmyUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.hide_image_rounded), title: l10n.manageMedia, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage( context, LocalSettings.settingsPageAccountMedia, @@ -433,7 +432,7 @@ class _LemmyUserSettingsPageState extends State { final account = resolveEffectiveAccount(context); exportSettings = await AccountRepositoryImpl(account: account).exportSettings(); } catch (e) { - showSnackbar(getExceptionErrorMessage(e)); + showThunderSnackbar(getExceptionErrorMessage(e)); return; } @@ -454,12 +453,12 @@ class _LemmyUserSettingsPageState extends State { ); if (savedFilePath?.isNotEmpty == true) { - showSnackbar(l10n.accountSettingsExportedSuccessfully(savedFilePath!)); + showThunderSnackbar(l10n.accountSettingsExportedSuccessfully(savedFilePath!)); } else { - showSnackbar(l10n.errorSavingAccountSettings); + showThunderSnackbar(l10n.errorSavingAccountSettings); } } catch (e) { - showSnackbar('${l10n.errorSavingAccountSettings} $e'); + showThunderSnackbar('${l10n.errorSavingAccountSettings} $e'); } } @@ -477,16 +476,16 @@ class _LemmyUserSettingsPageState extends State { if (filePath != null) { importSettings = await File(filePath).readAsString(); } else { - showSnackbar(l10n.errorLoadingAccountSettings); + showThunderSnackbar(l10n.errorLoadingAccountSettings); return; } } catch (e) { if (e is FormatException) { - showSnackbar(l10n.errorParsingJson); + showThunderSnackbar(l10n.errorParsingJson); } else if ((e as PlatformException?)?.code == 'invalid_file_extension') { - showSnackbar(l10n.youMustSelectAJsonFile); + showThunderSnackbar(l10n.youMustSelectAJsonFile); } else { - showSnackbar('${l10n.errorLoadingAccountSettings} $e'); + showThunderSnackbar('${l10n.errorLoadingAccountSettings} $e'); } return; } @@ -499,13 +498,13 @@ class _LemmyUserSettingsPageState extends State { ); if (success) { - showSnackbar(appL10n.accountSettingsImportedSuccessfully); + showThunderSnackbar(appL10n.accountSettingsImportedSuccessfully); context.read().add(FetchProfileSettings()); } else { - showSnackbar(appL10n.errorImportingAccountSettings); + showThunderSnackbar(appL10n.errorImportingAccountSettings); } } catch (e) { - showSnackbar(getExceptionErrorMessage(e)); + showThunderSnackbar(getExceptionErrorMessage(e)); } } diff --git a/lib/src/features/user/presentation/pages/media_management_page.dart b/lib/src/features/user/presentation/pages/media_management_page.dart index d2c311457..fa412b9b4 100644 --- a/lib/src/features/user/presentation/pages/media_management_page.dart +++ b/lib/src/features/user/presentation/pages/media_management_page.dart @@ -12,14 +12,13 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/shared/name/full_name_widgets.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/config.dart'; import 'package:thunder/src/shared/media/media_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; class MediaManagementPage extends StatelessWidget { const MediaManagementPage({super.key, required this.account}); @@ -38,7 +37,7 @@ class MediaManagementPage extends StatelessWidget { return BlocConsumer( listener: (context, state) { if (state.status == UserMediaStatus.loadFailure && state.errorMessage?.isNotEmpty == true) { - showSnackbar( + showThunderSnackbar( state.errorMessage!, trailingIcon: Icons.refresh_rounded, trailingAction: () => context.read().loadMedia(), @@ -215,7 +214,7 @@ class MediaManagementPage extends StatelessWidget { child: Container( color: theme.dividerColor.withValues(alpha: 0.1), padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( + child: ThunderScalableText( l10n.noReferencesToImage, textAlign: TextAlign.center, style: theme.textTheme.titleSmall, @@ -277,7 +276,7 @@ class MediaManagementPage extends StatelessWidget { child: Container( color: theme.dividerColor.withValues(alpha: 0.1), padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( + child: ThunderScalableText( l10n.noImages, textAlign: TextAlign.center, style: theme.textTheme.titleSmall, diff --git a/lib/src/features/user/presentation/pages/piefed_user_settings_page.dart b/lib/src/features/user/presentation/pages/piefed_user_settings_page.dart index d5fc8454b..e16a54e03 100644 --- a/lib/src/features/user/presentation/pages/piefed_user_settings_page.dart +++ b/lib/src/features/user/presentation/pages/piefed_user_settings_page.dart @@ -66,7 +66,7 @@ class _PiefedUserSettingsPageState extends State { super.dispose(); } - List> _defaultSortOptions(Account account) { + List> _defaultSortOptions(Account account) { const allowedSortTypes = { PostSortType.hot, PostSortType.new_, @@ -81,13 +81,13 @@ class _PiefedUserSettingsPageState extends State { ].where((item) => allowedSortTypes.contains(item.payload)).toList(); } - List> _feedTypeOptions(Account account) { + List> _feedTypeOptions(Account account) { return FeedListType.values .where( (type) => type.platform == null || type.platform == account.platform, ) .map( - (type) => ListPickerItem( + (type) => ThunderListPickerItem( payload: type, icon: switch (type) { FeedListType.all => Icons.home_rounded, @@ -103,14 +103,14 @@ class _PiefedUserSettingsPageState extends State { .toList(); } - ListPickerItem _currentFeedTypeOption( + ThunderListPickerItem _currentFeedTypeOption( Account account, FeedListType? currentType, ) { return _feedTypeOptions(account).firstWhereOrNull( (item) => item.payload == currentType, ) ?? - ListPickerItem( + ThunderListPickerItem( payload: currentType ?? FeedListType.subscribed, icon: Icons.feed_rounded, label: currentType?.value ?? FeedListType.subscribed.value, @@ -118,7 +118,7 @@ class _PiefedUserSettingsPageState extends State { ); } - ListPickerItem _currentSortOption({ + ThunderListPickerItem _currentSortOption({ required Account account, required PostSortType? currentSortType, }) { @@ -129,7 +129,7 @@ class _PiefedUserSettingsPageState extends State { return options.firstWhereOrNull((item) => item.payload == currentSortType) ?? allPostSortTypeItems.firstWhere( (item) => item.payload == currentSortType, - orElse: () => ListPickerItem( + orElse: () => ThunderListPickerItem( payload: currentSortType, icon: Icons.sort_rounded, label: currentSortType.value, @@ -174,7 +174,7 @@ class _PiefedUserSettingsPageState extends State { final selectedLanguageIds = myUser?.discussionLanguages ?? const []; return [ - UserSettingsSectionHeader(title: l10n.general), + ThunderSectionHeader(title: l10n.general), ThunderSettingsTile( leading: const Icon(Icons.note_rounded), title: l10n.profileBio, @@ -186,7 +186,7 @@ class _PiefedUserSettingsPageState extends State { onLongPress: () => shareLocalSetting(context, LocalSettings.accountProfileBio), highlighted: settingToHighlight == LocalSettings.accountProfileBio, ), - UserSettingsSectionHeader( + ThunderSectionHeader( title: l10n.feedSettings, description: l10n.settingOverrideLabel, ), @@ -198,7 +198,7 @@ class _PiefedUserSettingsPageState extends State { onChanged: (_) async {}, disabled: isUpdating, isBottomModalScrollControlled: true, - customListPicker: BottomSheetListPicker( + customListPicker: ThunderBottomSheetListPicker( title: l10n.defaultFeedSortType, items: _defaultSortOptions(account), previouslySelected: localUser?.defaultSortType, @@ -265,9 +265,8 @@ class _PiefedUserSettingsPageState extends State { ThunderToggleOption( title: l10n.showBotAccounts, value: localUser?.showBotAccounts, - iconEnabled: Thunder.robot, - iconDisabled: Thunder.robot, - iconSpacing: 14.0, + iconEnabled: ThunderIcon.robot, + iconDisabled: ThunderIcon.robot, onChanged: (value) => context.read().updateSettings(showBotAccounts: value), disabled: isUpdating, highlightKey: settingToHighlightKey, @@ -290,7 +289,7 @@ class _PiefedUserSettingsPageState extends State { ), highlighted: settingToHighlight == LocalSettings.accountDefaultFeedType, ), - UserSettingsSectionHeader(title: l10n.contentManagement), + ThunderSectionHeader(title: l10n.contentManagement), ThunderSettingsTile( leading: const Icon(Icons.language_rounded), title: l10n.discussionLanguages, @@ -306,7 +305,7 @@ class _PiefedUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.block_rounded), title: l10n.blockSettingLabel, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage( context, LocalSettings.settingsPageAccountBlocks, @@ -315,11 +314,11 @@ class _PiefedUserSettingsPageState extends State { onLongPress: () => shareLocalSetting(context, LocalSettings.accountBlocks), highlighted: settingToHighlight == LocalSettings.accountBlocks, ), - UserSettingsSectionHeader(title: l10n.dangerZone), + ThunderSectionHeader(title: l10n.dangerZone), ThunderSettingsTile( leading: const Icon(Icons.password), title: l10n.changePassword, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => _openInstanceSettings( context, title: l10n.changePassword, @@ -335,7 +334,7 @@ class _PiefedUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.delete_forever_rounded), title: l10n.deleteAccount, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => _openInstanceSettings( context, title: l10n.deleteAccount, @@ -351,7 +350,7 @@ class _PiefedUserSettingsPageState extends State { ThunderSettingsTile( leading: const Icon(Icons.hide_image_rounded), title: l10n.manageMedia, - trailing: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + trailing: const ThunderSettingsChevronTrailing(), onTap: () => navigateToSettingPage( context, LocalSettings.settingsPageAccountMedia, diff --git a/lib/src/features/user/presentation/pages/user_settings_block_page.dart b/lib/src/features/user/presentation/pages/user_settings_block_page.dart index 7e3350a71..b7e18f937 100644 --- a/lib/src/features/user/presentation/pages/user_settings_block_page.dart +++ b/lib/src/features/user/presentation/pages/user_settings_block_page.dart @@ -16,7 +16,7 @@ import 'package:thunder/src/shared/name/full_name_widgets.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the user's blocked users, communities, and instances. class UserSettingsBlockPage extends StatefulWidget { @@ -224,17 +224,17 @@ class _UserSettingsBlockPageState extends State with Sing bool isBlock = (state.personBeingBlocked != 0 || state.communityBeingBlocked != 0 || state.instanceBeingBlocked != 0); if (state.status == UserBlocksStatus.failure && !isBlock) { - return showSnackbar(state.errorMessage ?? l10n.unexpectedError); + return showThunderSnackbar(state.errorMessage ?? l10n.unexpectedError); } if (state.status == UserBlocksStatus.failure) { - showSnackbar(l10n.failedToUnblock(state.errorMessage ?? l10n.missingErrorMessage)); + showThunderSnackbar(l10n.failedToUnblock(state.errorMessage ?? l10n.missingErrorMessage)); } else if (state.status == UserBlocksStatus.failedRevert) { - showSnackbar(l10n.failedToBlock(state.errorMessage ?? l10n.missingErrorMessage)); + showThunderSnackbar(l10n.failedToBlock(state.errorMessage ?? l10n.missingErrorMessage)); } else if (state.status == UserBlocksStatus.revert) { - showSnackbar(l10n.successfullyBlocked); + showThunderSnackbar(l10n.successfullyBlocked); } else if (state.status == UserBlocksStatus.successBlock) { - showSnackbar( + showThunderSnackbar( l10n.successfullyUnblocked, trailingIcon: Icons.undo_rounded, trailingAction: () { diff --git a/lib/src/features/user/presentation/state/user_media_cubit.dart b/lib/src/features/user/presentation/state/user_media_cubit.dart index 174e9e321..3d889b1f2 100644 --- a/lib/src/features/user/presentation/state/user_media_cubit.dart +++ b/lib/src/features/user/presentation/state/user_media_cubit.dart @@ -121,7 +121,7 @@ class UserMediaCubit extends Cubit { emit(state.copyWith(status: UserMediaStatus.searching, errorMessage: '', errorReason: null, imageSearchPosts: null, imageSearchComments: null)); try { - final url = Uri.https(account.instance, 'pictrs/image/$id').toString(); + final url = buildInstanceUrl(account.instance, '/pictrs/image/$id'); final postsResponse = await searchRepository.search(query: url, type: MetaSearchType.posts); final postsByUrlResponse = await searchRepository.search(query: url, type: MetaSearchType.url); diff --git a/lib/src/features/user/presentation/utils/user_session_utils.dart b/lib/src/features/user/presentation/utils/user_session_utils.dart index 5b61044d2..73140ae45 100644 --- a/lib/src/features/user/presentation/utils/user_session_utils.dart +++ b/lib/src/features/user/presentation/utils/user_session_utils.dart @@ -5,7 +5,7 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/session/api.dart'; -import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// Shows a logout confirmation dialog without mutating session state. Future showLogOutDialog(BuildContext context) async { diff --git a/lib/src/features/user/presentation/widgets/account_settings_listener.dart b/lib/src/features/user/presentation/widgets/account_settings_listener.dart index 5d66ec771..0dbc96175 100644 --- a/lib/src/features/user/presentation/widgets/account_settings_listener.dart +++ b/lib/src/features/user/presentation/widgets/account_settings_listener.dart @@ -2,10 +2,10 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/user/presentation/state/account_settings_cubit.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that listens to the [AccountSettingsCubit] and shows a snackbar on failure or success. /// When the account settings are successfully updated, it also triggers a refresh of the profile settings in the [ProfileBloc]. @@ -23,7 +23,7 @@ class AccountSettingsListener extends StatelessWidget { listenWhen: (previous, current) => previous.status != current.status && (current.status == AccountSettingsStatus.failure || current.status == AccountSettingsStatus.success), listener: (context, state) { if (state.status == AccountSettingsStatus.failure) { - showSnackbar(state.errorMessage ?? l10n.unexpectedError); + showThunderSnackbar(state.errorMessage ?? l10n.unexpectedError); } else if (state.status == AccountSettingsStatus.success) { context.read().add(FetchProfileSettings()); } diff --git a/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart b/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart index 6b66117ce..cc6863b7e 100644 --- a/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart +++ b/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart @@ -12,7 +12,7 @@ import 'package:thunder/src/shared/avatars/user_avatar.dart'; import 'package:thunder/src/shared/chips/user_chip.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar, showThunderDialog; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the actions that can be taken on a user enum UserBottomSheetAction { @@ -151,7 +151,7 @@ class _UserActionBottomSheetState extends State { case UserBottomSheetAction.blockUser: Navigator.of(context).pop(); await userRepository.blockUser(widget.user.id, true); - showSnackbar(l10n.successfullyBlockedUser(widget.user.displayNameOrName)); + showThunderSnackbar(l10n.successfullyBlockedUser(widget.user.displayNameOrName)); widget.onAction(UserAction.block, null); break; case UserBottomSheetAction.messageUser: @@ -165,7 +165,7 @@ class _UserActionBottomSheetState extends State { case UserBottomSheetAction.unblockUser: Navigator.of(context).pop(); await userRepository.blockUser(widget.user.id, false); - showSnackbar(l10n.successfullyUnblockedUser(widget.user.displayNameOrName)); + showThunderSnackbar(l10n.successfullyUnblockedUser(widget.user.displayNameOrName)); widget.onAction(UserAction.block, null); break; case UserBottomSheetAction.addUserLabel: @@ -180,7 +180,7 @@ class _UserActionBottomSheetState extends State { Navigator.of(context).pop(); final user = await communityRepository.banUserFromCommunity(userId: widget.user.id, communityId: widget.communityId!, ban: false); if (!user.status.banned) { - showSnackbar(l10n.unbannedUserFromCommunity(widget.user.displayNameOrName)); + showThunderSnackbar(l10n.unbannedUserFromCommunity(widget.user.displayNameOrName)); } widget.onAction(UserAction.banFromCommunity, null); break; @@ -188,7 +188,7 @@ class _UserActionBottomSheetState extends State { Navigator.of(context).pop(); final moderators = await communityRepository.addModerator(userId: widget.user.id, communityId: widget.communityId!, added: true); if (moderators.where((m) => m.id == widget.user.id).isNotEmpty) { - showSnackbar(l10n.addedUserAsCommunityModerator(widget.user.displayNameOrName)); + showThunderSnackbar(l10n.addedUserAsCommunityModerator(widget.user.displayNameOrName)); } widget.onAction(UserAction.addModerator, null); break; @@ -196,7 +196,7 @@ class _UserActionBottomSheetState extends State { Navigator.of(context).pop(); final moderators = await communityRepository.addModerator(userId: widget.user.id, communityId: widget.communityId!, added: false); if (moderators.where((m) => m.id == widget.user.id).isEmpty) { - showSnackbar(l10n.removedUserAsCommunityModerator(widget.user.displayNameOrName)); + showThunderSnackbar(l10n.removedUserAsCommunityModerator(widget.user.displayNameOrName)); } widget.onAction(UserAction.addModerator, null); break; @@ -219,7 +219,7 @@ class _UserActionBottomSheetState extends State { final user = await communityRepository.banUserFromCommunity(userId: widget.user.id, communityId: widget.communityId!, ban: true, reason: controller.text, removeData: removeData); if (user.status.banned) { - showSnackbar(l10n.successfullyBannedUser(widget.user.displayNameOrName)); + showThunderSnackbar(l10n.successfullyBannedUser(widget.user.displayNameOrName)); } widget.onAction(UserAction.banFromCommunity, null); @@ -309,7 +309,7 @@ class _UserActionBottomSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ ...userActions.map( - (userPostAction) => BottomSheetAction( + (userPostAction) => ThunderBottomSheetAction( leading: Icon(userPostAction.icon), title: userPostAction.name, onTap: () => performAction(userPostAction), @@ -318,12 +318,12 @@ class _UserActionBottomSheetState extends State { if (isModerator && moderatorActions.isNotEmpty) ...[ const ThunderDivider(sliver: false, padding: false), ...moderatorActions.map( - (userPostAction) => BottomSheetAction( + (userPostAction) => ThunderBottomSheetAction( leading: Icon(userPostAction.icon), trailing: Padding( padding: const EdgeInsets.only(left: 1), child: Icon( - Thunder.shield, + ThunderIcon.shield, size: 20, color: Color.alphaBlend(theme.colorScheme.primary.withValues(alpha: 0.4), Colors.green), ), diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header.dart b/lib/src/features/user/presentation/widgets/user_header/user_header.dart index 6e1d26f51..b574edf38 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header.dart @@ -10,7 +10,7 @@ import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; import 'package:thunder/src/shared/media/image_preview.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderIconLabel; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays a user's header information and related actions. /// diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart index 054b4a166..1269eec4a 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart @@ -11,7 +11,7 @@ import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays relevant actions for a user in a scrollable chip list. class UserHeaderActions extends StatelessWidget { diff --git a/lib/src/features/user/presentation/widgets/user_information.dart b/lib/src/features/user/presentation/widgets/user_information.dart index a9a65144e..0d1a4acc0 100644 --- a/lib/src/features/user/presentation/widgets/user_information.dart +++ b/lib/src/features/user/presentation/widgets/user_information.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; @@ -48,23 +49,23 @@ class _UserInformationState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ UserHeader(user: widget.user, moderates: widget.moderates, condensed: true), - SidebarSectionHeader(value: l10n.profileBio), + ThunderSectionHeader(title: l10n.profileBio, variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: CommonMarkdownBody(body: widget.user.bio ?? '_${l10n.noProfileBioSet}_', imageMaxWidth: MediaQuery.of(context).size.width, launchContext: widget.launchContext), ), - SidebarSectionHeader(value: l10n.stats), + ThunderSectionHeader(title: l10n.stats, variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: UserStatsList(user: widget.user), ), - SidebarSectionHeader(value: l10n.activity), + ThunderSectionHeader(title: l10n.activity, variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: UserActivityList(user: widget.user), ), if (widget.moderates.isNotEmpty) ...[ - SidebarSectionHeader(value: l10n.moderates), + ThunderSectionHeader(title: l10n.moderates, variant: ThunderSectionHeaderVariant.sidebar), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: UserModeratorList(launchContext: widget.launchContext, account: widget.account, moderates: widget.moderates), @@ -91,18 +92,18 @@ class UserStatsList extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SidebarStat( + ThunderSidebarStat( icon: Icons.cake_rounded, - value: '${l10n.joined(DateFormat.yMMMMd().format(user.published))} · ${l10n.ago(formatTimeToString(dateTime: user.published.toIso8601String()))}', + label: '${l10n.joined(DateFormat.yMMMMd().format(user.published))} · ${l10n.ago(formatTimeToString(dateTime: user.published.toIso8601String()))}', ), const SizedBox(height: 8.0), - SidebarStat( + ThunderSidebarStat( icon: Icons.wysiwyg_rounded, - value: l10n.totalPosts(NumberFormat("#,###,###,###").format(user.counts.posts)), + label: l10n.totalPosts(NumberFormat("#,###,###,###").format(user.counts.posts)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.chat_rounded, - value: l10n.totalComments(NumberFormat("#,###,###,###").format(user.counts.comments)), + label: l10n.totalComments(NumberFormat("#,###,###,###").format(user.counts.comments)), ), ], ); @@ -144,17 +145,17 @@ class UserActivityList extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SidebarStat( + ThunderSidebarStat( icon: Icons.wysiwyg_rounded, - value: l10n.averagePosts(NumberFormat("#,###,###,###").format(postsPerMonth)), + label: l10n.averagePosts(NumberFormat("#,###,###,###").format(postsPerMonth)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.chat_rounded, - value: l10n.averageComments(NumberFormat("#,###,###,###").format(commentsPerMonth)), + label: l10n.averageComments(NumberFormat("#,###,###,###").format(commentsPerMonth)), ), - SidebarStat( + ThunderSidebarStat( icon: Icons.score_rounded, - value: l10n.averageContributions(NumberFormat("#,###,###,###").format(totalContributionsPerMonth)), + label: l10n.averageContributions(NumberFormat("#,###,###,###").format(totalContributionsPerMonth)), ), ], ); @@ -216,57 +217,3 @@ class UserModeratorList extends StatelessWidget { ); } } - -/// A widget that displays a section header in the sidebar. -class SidebarSectionHeader extends StatelessWidget { - /// The header title. - final String value; - - const SidebarSectionHeader({super.key, required this.value}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 12.0, bottom: 8.0, left: 8.0, right: 8.0), - child: Row( - children: [ - Text(value), - const Expanded(child: Divider(height: 5.0, thickness: 2.0, indent: 15.0)), - ], - ), - ); - } -} - -/// A widget that displays a statistic in the sidebar. -class SidebarStat extends StatelessWidget { - /// The icon to display for the statistic. - final IconData icon; - - /// The value of the statistic. - final String value; - - const SidebarStat({super.key, required this.icon, required this.value}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2.0, bottom: 2.0), - child: Icon( - icon, - size: 18.0, - color: theme.colorScheme.onSurface.withValues(alpha: 0.65), - ), - ), - Text( - value, - style: TextStyle(color: theme.textTheme.titleSmall?.color?.withValues(alpha: 0.65)), - ), - ], - ); - } -} diff --git a/lib/src/features/user/presentation/widgets/user_label_chip.dart b/lib/src/features/user/presentation/widgets/user_label_chip.dart index da5d839f4..e8b5d35ae 100644 --- a/lib/src/features/user/presentation/widgets/user_label_chip.dart +++ b/lib/src/features/user/presentation/widgets/user_label_chip.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/shared/theme/color_utils.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays a user's label in a chip format. class UserLabelChip extends StatelessWidget { @@ -42,7 +42,7 @@ class UserLabelChip extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 5.0), - child: ScalableText( + child: ThunderScalableText( label, textScaleFactor: metadataFontSizeScale.textScaleFactor, ), diff --git a/lib/src/features/user/presentation/widgets/user_selector.dart b/lib/src/features/user/presentation/widgets/user_selector.dart index dcc10cc30..be3802241 100644 --- a/lib/src/features/user/presentation/widgets/user_selector.dart +++ b/lib/src/features/user/presentation/widgets/user_selector.dart @@ -8,7 +8,7 @@ import 'package:thunder/src/features/session/api.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/user/presentation/widgets/account_picker_sheet.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// A widget that displays the currently selected user account with the ability to switch between accounts. /// @@ -213,23 +213,23 @@ class _UserSelectorState extends State { ); if (widget.communityActorId?.isNotEmpty == true && resolvedContent.community == null) { - showSnackbar(l10n.unableToFindCommunityOnInstance); + showThunderSnackbar(l10n.unableToFindCommunityOnInstance); return null; } if (widget.postActorId?.isNotEmpty == true && resolvedContent.post == null) { - showSnackbar(l10n.accountSwitchPostNotFound(newAccount.instance)); + showThunderSnackbar(l10n.accountSwitchPostNotFound(newAccount.instance)); return null; } if (widget.parentCommentActorId?.isNotEmpty == true && resolvedContent.parentComment == null) { - showSnackbar(l10n.accountSwitchParentCommentNotFound(newAccount.instance)); + showThunderSnackbar(l10n.accountSwitchParentCommentNotFound(newAccount.instance)); return null; } return resolvedContent; } catch (e) { - showSnackbar(e.toString()); + showThunderSnackbar(e.toString()); return null; } } diff --git a/lib/src/features/user/presentation/widgets/user_settings_page_scaffold.dart b/lib/src/features/user/presentation/widgets/user_settings_page_scaffold.dart index a2301cd80..f0e74e751 100644 --- a/lib/src/features/user/presentation/widgets/user_settings_page_scaffold.dart +++ b/lib/src/features/user/presentation/widgets/user_settings_page_scaffold.dart @@ -152,41 +152,3 @@ class _AccountSummaryHeader extends StatelessWidget { ); } } - -/// Shared section header for account settings groups. -class UserSettingsSectionHeader extends StatelessWidget { - const UserSettingsSectionHeader({ - super.key, - required this.title, - this.description, - }); - - final String title; - final String? description; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.titleMedium), - if (description != null) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - description!, - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - color: theme.colorScheme.onSurface.withValues(alpha: 0.75), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/foundation/config/app_config.dart b/lib/src/foundation/config/app_config.dart index 051b176eb..5f10c45f2 100644 --- a/lib/src/foundation/config/app_config.dart +++ b/lib/src/foundation/config/app_config.dart @@ -1 +1 @@ -const String currentVersion = '0.9.0-3+98'; +const String currentVersion = '0.9.0-3+98'; diff --git a/lib/src/foundation/config/global_context.dart b/lib/src/foundation/config/global_context.dart index 3a7e481eb..279466035 100644 --- a/lib/src/foundation/config/global_context.dart +++ b/lib/src/foundation/config/global_context.dart @@ -1,10 +1,10 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; - -class GlobalContext { - static GlobalKey scaffoldMessengerKey = GlobalKey(); - - static BuildContext get context => scaffoldMessengerKey.currentContext!; - static AppLocalizations get l10n => AppLocalizations.of(context)!; -} +import 'package:flutter/material.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; + +class GlobalContext { + static GlobalKey scaffoldMessengerKey = GlobalKey(); + + static BuildContext get context => scaffoldMessengerKey.currentContext!; + static AppLocalizations get l10n => AppLocalizations.of(context)!; +} diff --git a/lib/src/foundation/networking/api_client_factory.dart b/lib/src/foundation/networking/api_client_factory.dart index e63711d0d..cfc7c27c6 100644 --- a/lib/src/foundation/networking/api_client_factory.dart +++ b/lib/src/foundation/networking/api_client_factory.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:version/version.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; import 'package:thunder/src/foundation/utils/cache/platform_version_cache.dart'; import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; import 'package:thunder/src/foundation/networking/lemmy/lemmy_v3_api_client.dart'; @@ -41,13 +42,14 @@ class ApiClientFactory { String instance, { http.Client? httpClient, }) async { + final normalizedInstance = normalizeInstanceAuthority(instance) ?? instance; final client = httpClient ?? http.Client(); final ownsClient = httpClient == null; try { for (final path in const ['/api/v4/site', '/api/v3/site']) { try { - final response = await client.get(Uri.https(instance, path)).timeout(const Duration(seconds: 5)); + final response = await client.get(buildInstanceUri(normalizedInstance, path)).timeout(const Duration(seconds: 5)); if (response.statusCode != 200) continue; final decoded = jsonDecode(response.body); @@ -56,7 +58,7 @@ class ApiClientFactory { final versionString = decoded['version']?.toString(); if (versionString == null || versionString.isEmpty) continue; - PlatformVersionCache().trySet(instance, versionString); + PlatformVersionCache().trySet(normalizedInstance, versionString); return Version.parse(versionString); } catch (_) { continue; @@ -77,8 +79,9 @@ class ApiClientFactory { bool debug, http.Client? httpClient, ) async { - var version = PlatformVersionCache().get(account.instance); - version ??= await probeLemmySiteVersion(account.instance, httpClient: httpClient); + final normalizedInstance = normalizeInstanceAuthority(account.instance) ?? account.instance; + var version = PlatformVersionCache().get(normalizedInstance); + version ??= await probeLemmySiteVersion(normalizedInstance, httpClient: httpClient); if (version != null && _isLemmyApiV4(version)) { return LemmyV4ApiClient( @@ -103,7 +106,8 @@ class ApiClientFactory { bool debug, http.Client? httpClient, ) { - final version = PlatformVersionCache().get(account.instance); + final normalizedInstance = normalizeInstanceAuthority(account.instance) ?? account.instance; + final version = PlatformVersionCache().get(normalizedInstance); return PiefedApiClient( account: account, diff --git a/lib/src/foundation/networking/base_api_client.dart b/lib/src/foundation/networking/base_api_client.dart index 6933d6b1d..9ae66fe4b 100644 --- a/lib/src/foundation/networking/base_api_client.dart +++ b/lib/src/foundation/networking/base_api_client.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import 'package:version/version.dart'; import 'package:thunder/src/foundation/errors/api_exception.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; import 'package:thunder/src/foundation/utils/check_github_update.dart'; import 'package:thunder/src/foundation/contracts/account.dart'; @@ -130,22 +131,22 @@ abstract class BaseApiClient { case HttpMethod.get: // Convert all values to strings for query parameters final queryParams = data.map((k, v) => MapEntry(k, v.toString())); - uri = Uri.https(account.instance, endpoint, queryParams.isEmpty ? null : queryParams); + uri = buildInstanceUri(account.instance, endpoint, queryParameters: queryParams.isEmpty ? null : queryParams); if (debug) debugPrint('$platformName API: GET $uri'); response = await httpClient.get(uri, headers: headers); case HttpMethod.post: - uri = Uri.https(account.instance, endpoint); + uri = buildInstanceUri(account.instance, endpoint); if (debug) debugPrint('$platformName API: POST $uri'); response = await httpClient.post(uri, body: jsonEncode(data), headers: headers); case HttpMethod.put: - uri = Uri.https(account.instance, endpoint); + uri = buildInstanceUri(account.instance, endpoint); if (debug) debugPrint('$platformName API: PUT $uri'); response = await httpClient.put(uri, body: jsonEncode(data), headers: headers); case HttpMethod.delete: - uri = Uri.https(account.instance, endpoint); + uri = buildInstanceUri(account.instance, endpoint); if (debug) debugPrint('$platformName API: DELETE $uri'); response = await httpClient.delete(uri, body: data.isEmpty ? null : jsonEncode(data), headers: headers); } diff --git a/lib/src/foundation/networking/discovery/instance_discovery_service.dart b/lib/src/foundation/networking/discovery/instance_discovery_service.dart index f21c9a38d..8edfcf9ab 100644 --- a/lib/src/foundation/networking/discovery/instance_discovery_service.dart +++ b/lib/src/foundation/networking/discovery/instance_discovery_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/utils/cache/platform_version_cache.dart'; import 'package:thunder/src/features/account/api.dart'; @@ -114,16 +115,7 @@ Future loadInstanceInfo( InstanceRepository _defaultInstanceRepositoryFactory(Account account) => InstanceRepositoryImpl(account: account); String? normalizeInstanceHost(String? url) { - final trimmed = url?.trim(); - if (trimmed == null || trimmed.isEmpty) return null; - - final lowerTrimmed = trimmed.toLowerCase(); - final value = lowerTrimmed.startsWith('http://') || lowerTrimmed.startsWith('https://') ? trimmed : 'https://$trimmed'; - final uri = Uri.tryParse(value); - final host = uri?.host.trim().toLowerCase(); - if (host == null || host.isEmpty) return null; - - return host; + return normalizeInstanceAuthority(url); } /// Determines the proper ThreadiversePlatform by fetching software information from nodeinfo. @@ -139,16 +131,7 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? final instanceHost = normalizeInstanceHost(url); if (instanceHost == null) return null; - final rawUrl = url.trim().toLowerCase(); - final scheme = rawUrl.startsWith('http://') ? 'http' : 'https'; - final uri = Uri.parse('$scheme://$instanceHost'); - - final nodeInfoUri = Uri( - scheme: uri.scheme, - host: uri.host, - port: uri.port, - path: '/.well-known/nodeinfo', - ); + final nodeInfoUri = buildInstanceUri(instanceHost, '/.well-known/nodeinfo'); final response = await http.get(nodeInfoUri).timeout(timeout ?? const Duration(seconds: 5)); diff --git a/lib/src/foundation/networking/error_message_utils.dart b/lib/src/foundation/networking/error_message_utils.dart index deb6950c6..aa596aa8a 100644 --- a/lib/src/foundation/networking/error_message_utils.dart +++ b/lib/src/foundation/networking/error_message_utils.dart @@ -1,39 +1,39 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/src/foundation/errors/api_exception.dart'; - -/// Generates a user-friendly error message from an exception (or any thrown object) -String getExceptionErrorMessage(Object? e, {String? additionalInfo}) { - if (e is ApiException) { - return getErrorMessage(GlobalContext.context, e.message, additionalInfo: e.errorCode) ?? e.message; - } - - if (e is TimeoutException) { - return AppLocalizations.of(GlobalContext.context)!.timeoutErrorMessage; - } - - return e.toString(); -} - -/// Attempts to retrieve a localized error message for the given [apiErrorCode]. -/// Returns null if not found. -String? getErrorMessage(BuildContext context, String apiErrorCode, {String? additionalInfo}) { - final AppLocalizations l10n = AppLocalizations.of(context)!; - - return switch (apiErrorCode) { - "cant_block_admin" => l10n.cantBlockAdmin, - "cant_block_yourself" => l10n.cantBlockYourself, - "only_mods_can_post_in_community" => l10n.onlyModsCanPostInCommunity, - "couldnt_create_report" => l10n.couldntCreateReport, - "language_not_allowed" => l10n.languageNotAllowed, - "couldnt_find_community" => additionalInfo != null ? l10n.unableToFindCommunityName(additionalInfo) : l10n.unableToFindCommunity, - "couldnt_find_person" => additionalInfo != null ? l10n.unableToFindUserName(additionalInfo) : l10n.unableToFindUser, - "couldnt_find_post" => l10n.couldntFindPost, - "network_error" => l10n.networkErrorMessage, - "rate_limit_error" => l10n.rateLimitErrorMessage, - _ => apiErrorCode, - }; -} +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/errors/api_exception.dart'; + +/// Generates a user-friendly error message from an exception (or any thrown object) +String getExceptionErrorMessage(Object? e, {String? additionalInfo}) { + if (e is ApiException) { + return getErrorMessage(GlobalContext.context, e.message, additionalInfo: e.errorCode) ?? e.message; + } + + if (e is TimeoutException) { + return AppLocalizations.of(GlobalContext.context)!.timeoutErrorMessage; + } + + return e.toString(); +} + +/// Attempts to retrieve a localized error message for the given [apiErrorCode]. +/// Returns null if not found. +String? getErrorMessage(BuildContext context, String apiErrorCode, {String? additionalInfo}) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return switch (apiErrorCode) { + "cant_block_admin" => l10n.cantBlockAdmin, + "cant_block_yourself" => l10n.cantBlockYourself, + "only_mods_can_post_in_community" => l10n.onlyModsCanPostInCommunity, + "couldnt_create_report" => l10n.couldntCreateReport, + "language_not_allowed" => l10n.languageNotAllowed, + "couldnt_find_community" => additionalInfo != null ? l10n.unableToFindCommunityName(additionalInfo) : l10n.unableToFindCommunity, + "couldnt_find_person" => additionalInfo != null ? l10n.unableToFindUserName(additionalInfo) : l10n.unableToFindUser, + "couldnt_find_post" => l10n.couldntFindPost, + "network_error" => l10n.networkErrorMessage, + "rate_limit_error" => l10n.rateLimitErrorMessage, + _ => apiErrorCode, + }; +} diff --git a/lib/src/foundation/networking/instance_uri.dart b/lib/src/foundation/networking/instance_uri.dart new file mode 100644 index 000000000..b943517d1 --- /dev/null +++ b/lib/src/foundation/networking/instance_uri.dart @@ -0,0 +1,59 @@ +/// Normalizes a user-entered instance into an authority (`host` or `host:port`). +String? normalizeInstanceAuthority(String? input) { + final trimmed = input?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + + final value = trimmed.startsWith('http://') || trimmed.startsWith('https://') ? trimmed : 'https://$trimmed'; + final uri = Uri.tryParse(value); + final host = uri?.host.trim().toLowerCase(); + if (host == null || host.isEmpty) return null; + + return uri!.hasPort ? '$host:${uri.port}' : host; +} + +/// Returns whether [instance] points at a local development host. +bool isLocalInstanceAuthority(String instance) { + final authority = normalizeInstanceAuthority(instance); + if (authority == null) return false; + + final uri = Uri.tryParse('https://$authority'); + final host = uri?.host.toLowerCase(); + if (host == null || host.isEmpty) return false; + + return host == 'localhost' || host == 'host.docker.internal' || host == '10.0.2.2' || host == '0.0.0.0' || _isPrivateIpv4Address(host) || RegExp(r'^127(?:\.\d{1,3}){3}$').hasMatch(host); +} + +bool _isPrivateIpv4Address(String host) { + final parts = host.split('.'); + if (parts.length != 4) return false; + + final octets = parts.map(int.tryParse).toList(); + if (octets.any((octet) => octet == null || octet < 0 || octet > 255)) return false; + + final first = octets[0]!; + final second = octets[1]!; + + return first == 10 || first == 192 && second == 168 || first == 172 && second >= 16 && second <= 31 || first == 169 && second == 254; +} + +/// Builds an API or media URI for an instance authority. +Uri buildInstanceUri( + String instance, + String path, { + Map? queryParameters, +}) { + final authority = normalizeInstanceAuthority(instance) ?? instance; + final authorityUri = Uri.parse('https://$authority'); + final scheme = isLocalInstanceAuthority(authority) ? 'http' : 'https'; + + return Uri( + scheme: scheme, + host: authorityUri.host, + port: authorityUri.hasPort ? authorityUri.port : null, + path: path, + queryParameters: queryParameters?.isEmpty == true ? null : queryParameters, + ); +} + +/// Builds a URL string for an instance authority. +String buildInstanceUrl(String instance, String path) => buildInstanceUri(instance, path).toString(); diff --git a/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart index a37886040..b17d95509 100644 --- a/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart +++ b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart @@ -13,6 +13,7 @@ import 'package:thunder/src/foundation/primitives/models/thunder_site.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_site_response.dart'; import 'package:thunder/src/foundation/errors/api_exception.dart'; import 'package:thunder/src/foundation/networking/base_api_client.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; import 'package:thunder/src/foundation/networking/lemmy/lemmy_api_client_defaults.dart'; import 'package:thunder/src/foundation/networking/lemmy/lemmy_private_message_utils.dart'; import 'package:thunder/src/foundation/networking/lemmy/modlog_parsers.dart'; @@ -898,7 +899,7 @@ class LemmyV3ApiClient extends BaseApiClient with LemmyApiClientDefaults { Future uploadImage(String filePath) async { final decoded = await uploadMultipartImage( httpClient: httpClient, - uri: Uri.https(account.instance, '/pictrs/image'), + uri: buildInstanceUri(account.instance, '/pictrs/image'), headers: buildHeaders(), fieldName: 'images[]', filePath: filePath, @@ -923,7 +924,7 @@ class LemmyV3ApiClient extends BaseApiClient with LemmyApiClientDefaults { AccountMediaItem _accountMediaItemFromLegacy(Map image, String instance) { final localImage = image['local_image'] as Map? ?? image; final alias = localImage['pictrs_alias']?.toString() ?? localImage['file']?.toString() ?? ''; - final url = image['url']?.toString() ?? Uri.https(instance, '/pictrs/image/$alias').toString(); + final url = image['url']?.toString() ?? buildInstanceUrl(instance, '/pictrs/image/$alias'); return AccountMediaItem( alias: alias, diff --git a/lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart b/lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart index 171912916..5124a81c8 100644 --- a/lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart +++ b/lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart @@ -26,6 +26,7 @@ import 'package:thunder/src/foundation/primitives/models/thunder_site_response.d import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; import 'package:thunder/src/features/account/domain/models/account_media.dart'; import 'package:thunder/src/features/account/domain/models/account_settings_update.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; /// Lemmy API client for Lemmy 1.0+ (`/api/v4`). /// @@ -828,7 +829,7 @@ class LemmyV4ApiClient extends BaseApiClient with LemmyApiClientDefaults { Future uploadImage(String filePath) async { final decoded = await uploadMultipartImage( httpClient: httpClient, - uri: Uri.https(account.instance, '$basePath/image'), + uri: buildInstanceUri(account.instance, '$basePath/image'), headers: buildHeaders(), fieldName: 'image', filePath: filePath, @@ -923,7 +924,7 @@ AccountMediaItem _accountMediaItemFromLemmyV4(Map image, String final alias = localImage['pictrs_alias']?.toString() ?? ''; return AccountMediaItem( alias: alias, - url: Uri.https(instance, '/pictrs/image/$alias').toString(), + url: buildInstanceUrl(instance, '/pictrs/image/$alias'), uploadedAt: DateTime.tryParse((localImage['published_at'] ?? '').toString()), thumbnailForPostId: localImage['thumbnail_for_post_id'] as int?, ); diff --git a/lib/src/foundation/networking/networking.dart b/lib/src/foundation/networking/networking.dart index c342ccb16..19e81558e 100644 --- a/lib/src/foundation/networking/networking.dart +++ b/lib/src/foundation/networking/networking.dart @@ -1,6 +1,7 @@ export 'api_client_factory.dart'; export 'base_api_client.dart'; export 'error_message_utils.dart'; +export 'instance_uri.dart'; export 'lemmy/lemmy_v3_api_client.dart'; export 'lemmy/lemmy_v4_api_client.dart'; export 'mappers/mappers.dart'; diff --git a/lib/src/foundation/networking/piefed/piefed_api_client.dart b/lib/src/foundation/networking/piefed/piefed_api_client.dart index f8e8ebddc..7c145ed7f 100644 --- a/lib/src/foundation/networking/piefed/piefed_api_client.dart +++ b/lib/src/foundation/networking/piefed/piefed_api_client.dart @@ -15,6 +15,7 @@ import 'package:thunder/src/foundation/primitives/models/thunder_site_response.d import 'package:thunder/src/foundation/networking/mappers/primitive_mappers.dart'; import 'package:thunder/src/foundation/errors/api_exception.dart'; import 'package:thunder/src/foundation/networking/base_api_client.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; import 'package:thunder/src/foundation/networking/lemmy/modlog_parsers.dart'; import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; @@ -1117,7 +1118,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { Future _uploadImageTo(String endpoint, String filePath) async { final decoded = await uploadMultipartImage( httpClient: httpClient, - uri: Uri.https(account.instance, endpoint), + uri: buildInstanceUri(account.instance, endpoint), headers: buildHeaders(), fieldName: 'file', filePath: filePath, @@ -1147,7 +1148,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { AccountMediaItem _accountMediaItemFromPiefed(Map image, String instance) { final localImage = image['local_image'] as Map? ?? image; final alias = localImage['pictrs_alias']?.toString() ?? localImage['file']?.toString() ?? ''; - final url = image['url']?.toString() ?? Uri.https(instance, '/pictrs/image/$alias').toString(); + final url = image['url']?.toString() ?? buildInstanceUrl(instance, '/pictrs/image/$alias'); return AccountMediaItem( alias: alias, url: url, diff --git a/lib/src/foundation/networking/utils/upload_image_utils.dart b/lib/src/foundation/networking/utils/upload_image_utils.dart index b9caab381..2d1ea667a 100644 --- a/lib/src/foundation/networking/utils/upload_image_utils.dart +++ b/lib/src/foundation/networking/utils/upload_image_utils.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; /// Parses an image upload API response into a public image URL. String parseUploadImageUrl( @@ -20,7 +21,7 @@ String parseUploadImageUrl( if (response['files'] != null && (response['files'] as List).isNotEmpty) { final filename = response['files'][0]['file']; - return 'https://$instance/pictrs/image/$filename'; + return buildInstanceUrl(instance, '/pictrs/image/$filename'); } throw ApiErrorException( diff --git a/lib/src/foundation/persistence/database/type_converters.dart b/lib/src/foundation/persistence/database/type_converters.dart index 3983a23cd..72dd06a7d 100644 --- a/lib/src/foundation/persistence/database/type_converters.dart +++ b/lib/src/foundation/persistence/database/type_converters.dart @@ -1,32 +1,32 @@ -import 'package:drift/drift.dart'; - -import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; -import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; - -class DraftTypeConverter extends TypeConverter { - const DraftTypeConverter(); - - @override - DraftType fromSql(String fromDb) { - return DraftType.values.byName(fromDb); - } - - @override - String toSql(DraftType value) { - return value.name; - } -} - -class ThreadiversePlatformConverter extends TypeConverter { - const ThreadiversePlatformConverter(); - - @override - ThreadiversePlatform? fromSql(String? fromDb) { - return ThreadiversePlatform.fromString(fromDb); - } - - @override - String? toSql(ThreadiversePlatform? value) { - return value?.toStringValue(); - } -} +import 'package:drift/drift.dart'; + +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; + +class DraftTypeConverter extends TypeConverter { + const DraftTypeConverter(); + + @override + DraftType fromSql(String fromDb) { + return DraftType.values.byName(fromDb); + } + + @override + String toSql(DraftType value) { + return value.name; + } +} + +class ThreadiversePlatformConverter extends TypeConverter { + const ThreadiversePlatformConverter(); + + @override + ThreadiversePlatform? fromSql(String? fromDb) { + return ThreadiversePlatform.fromString(fromDb); + } + + @override + String? toSql(ThreadiversePlatform? value) { + return value?.toStringValue(); + } +} diff --git a/lib/src/foundation/primitives/enums/action_color.dart b/lib/src/foundation/primitives/enums/action_color.dart index 2129e37a4..06fd6b3cb 100644 --- a/lib/src/foundation/primitives/enums/action_color.dart +++ b/lib/src/foundation/primitives/enums/action_color.dart @@ -1,45 +1,45 @@ -import 'package:flutter/material.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -class ActionColor { - static const String orange = '0xFFF57C00'; // Colors.orange.shade700 - static const String blue = '0xFF1976D2'; // Colors.blue.shade700 - static const String purple = '0xFF7B1FA2'; // Colors.purple.shade700 - static const String teal = '0xFF4DB6AC'; // Colors.teal.shade300 - static const String green = '0xFF388E3C'; // Colors.green.shade700 - static const String red = '0xFFD32F2F'; // Colors.red.shade700 - - final String colorRaw; - - Color get color => Color(int.parse(colorRaw)); - - const ActionColor.fromString({required this.colorRaw}); - - @override - String toString() => color.toARGB32().toString(); - - String label(BuildContext context) { - final AppLocalizations l10n = AppLocalizations.of(context)!; - - return switch (colorRaw) { - orange => l10n.orange, - blue => l10n.blue, - purple => l10n.purple, - teal => l10n.teal, - green => l10n.green, - red => l10n.red, - _ => throw Exception('Unknown color'), - }; - } - - static List getPossibleValues(ActionColor currentValue) { - return [ - currentValue.colorRaw == orange ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.orange), - currentValue.colorRaw == blue ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.blue), - currentValue.colorRaw == purple ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.purple), - currentValue.colorRaw == teal ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.teal), - currentValue.colorRaw == green ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.green), - currentValue.colorRaw == red ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.red), - ]; - } -} +import 'package:flutter/material.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; + +class ActionColor { + static const String orange = '0xFFF57C00'; // Colors.orange.shade700 + static const String blue = '0xFF1976D2'; // Colors.blue.shade700 + static const String purple = '0xFF7B1FA2'; // Colors.purple.shade700 + static const String teal = '0xFF4DB6AC'; // Colors.teal.shade300 + static const String green = '0xFF388E3C'; // Colors.green.shade700 + static const String red = '0xFFD32F2F'; // Colors.red.shade700 + + final String colorRaw; + + Color get color => Color(int.parse(colorRaw)); + + const ActionColor.fromString({required this.colorRaw}); + + @override + String toString() => color.toARGB32().toString(); + + String label(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return switch (colorRaw) { + orange => l10n.orange, + blue => l10n.blue, + purple => l10n.purple, + teal => l10n.teal, + green => l10n.green, + red => l10n.red, + _ => throw Exception('Unknown color'), + }; + } + + static List getPossibleValues(ActionColor currentValue) { + return [ + currentValue.colorRaw == orange ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.orange), + currentValue.colorRaw == blue ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.blue), + currentValue.colorRaw == purple ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.purple), + currentValue.colorRaw == teal ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.teal), + currentValue.colorRaw == green ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.green), + currentValue.colorRaw == red ? currentValue : const ActionColor.fromString(colorRaw: ActionColor.red), + ]; + } +} diff --git a/lib/src/foundation/primitives/enums/browser_mode.dart b/lib/src/foundation/primitives/enums/browser_mode.dart index 4ed7c0544..e498dd421 100644 --- a/lib/src/foundation/primitives/enums/browser_mode.dart +++ b/lib/src/foundation/primitives/enums/browser_mode.dart @@ -1,5 +1,5 @@ -enum BrowserMode { - inApp, - customTabs, - external, -} +enum BrowserMode { + inApp, + customTabs, + external, +} diff --git a/lib/src/foundation/primitives/enums/draft_type.dart b/lib/src/foundation/primitives/enums/draft_type.dart index dbc309593..83eed0076 100644 --- a/lib/src/foundation/primitives/enums/draft_type.dart +++ b/lib/src/foundation/primitives/enums/draft_type.dart @@ -1,16 +1,16 @@ -// Note: Never remove an item from this list as it could cause database issues -enum DraftType { - commentEdit, - commentCreate, - commentCreateFromPost, - commentCreateFromComment, - postEdit, - postCreate, - postCreateGeneral, -} - -extension DraftTypeExtension on DraftType { - bool get isCommentCreate => this == DraftType.commentCreate || this == DraftType.commentCreateFromPost || this == DraftType.commentCreateFromComment; - - bool get isPostCreate => this == DraftType.postCreate || this == DraftType.postCreateGeneral; -} +// Note: Never remove an item from this list as it could cause database issues +enum DraftType { + commentEdit, + commentCreate, + commentCreateFromPost, + commentCreateFromComment, + postEdit, + postCreate, + postCreateGeneral, +} + +extension DraftTypeExtension on DraftType { + bool get isCommentCreate => this == DraftType.commentCreate || this == DraftType.commentCreateFromPost || this == DraftType.commentCreateFromComment; + + bool get isPostCreate => this == DraftType.postCreate || this == DraftType.postCreateGeneral; +} diff --git a/lib/src/foundation/primitives/enums/image_caching_mode.dart b/lib/src/foundation/primitives/enums/image_caching_mode.dart index fe109f637..8f714754c 100644 --- a/lib/src/foundation/primitives/enums/image_caching_mode.dart +++ b/lib/src/foundation/primitives/enums/image_caching_mode.dart @@ -1,4 +1,4 @@ -enum ImageCachingMode { - aggressive, - relaxed; -} +enum ImageCachingMode { + aggressive, + relaxed; +} diff --git a/lib/src/foundation/primitives/enums/meta_search_type.dart b/lib/src/foundation/primitives/enums/meta_search_type.dart index 81d48737d..80d3e4ce7 100644 --- a/lib/src/foundation/primitives/enums/meta_search_type.dart +++ b/lib/src/foundation/primitives/enums/meta_search_type.dart @@ -1,30 +1,30 @@ -import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; - -enum MetaSearchType { - all(searchType: 'All'), - comments(searchType: 'Comments'), - posts(searchType: 'Posts'), - communities(searchType: 'Communities'), - users(searchType: 'Users'), - url(searchType: 'Url'), - instances(), - ; - - /// The type of search to perform. - final String? searchType; - - /// The platform this meta search type is used on. If null, it is used on all platforms. - final ThreadiversePlatform? platform = null; - - const MetaSearchType({this.searchType}); - - /// Fetches the name of the meta search type. - String get name => - searchType ?? - switch (this) { - instances => AppLocalizations.of(GlobalContext.context)!.instance(2), - _ => '', - }; -} +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; + +enum MetaSearchType { + all(searchType: 'All'), + comments(searchType: 'Comments'), + posts(searchType: 'Posts'), + communities(searchType: 'Communities'), + users(searchType: 'Users'), + url(searchType: 'Url'), + instances(), + ; + + /// The type of search to perform. + final String? searchType; + + /// The platform this meta search type is used on. If null, it is used on all platforms. + final ThreadiversePlatform? platform = null; + + const MetaSearchType({this.searchType}); + + /// Fetches the name of the meta search type. + String get name => + searchType ?? + switch (this) { + instances => AppLocalizations.of(GlobalContext.context)!.instance(2), + _ => '', + }; +} diff --git a/lib/src/foundation/primitives/enums/nested_comment_indicator.dart b/lib/src/foundation/primitives/enums/nested_comment_indicator.dart index 415e1a91e..5117b6b39 100644 --- a/lib/src/foundation/primitives/enums/nested_comment_indicator.dart +++ b/lib/src/foundation/primitives/enums/nested_comment_indicator.dart @@ -1,29 +1,29 @@ -enum NestedCommentIndicatorStyle { - thick('Thick'), - thin('Thin'); - - final String value; - const NestedCommentIndicatorStyle(this.value); - - factory NestedCommentIndicatorStyle.fromJson(String value) => values.firstWhere((e) => e.value == value); - - String toJson() => value; - - @override - String toString() => value; -} - -enum NestedCommentIndicatorColor { - colorful('Colorful'), - monochrome('Monochrome'); - - final String value; - const NestedCommentIndicatorColor(this.value); - - factory NestedCommentIndicatorColor.fromJson(String value) => values.firstWhere((e) => e.value == value); - - String toJson() => value; - - @override - String toString() => value; -} +enum NestedCommentIndicatorStyle { + thick('Thick'), + thin('Thin'); + + final String value; + const NestedCommentIndicatorStyle(this.value); + + factory NestedCommentIndicatorStyle.fromJson(String value) => values.firstWhere((e) => e.value == value); + + String toJson() => value; + + @override + String toString() => value; +} + +enum NestedCommentIndicatorColor { + colorful('Colorful'), + monochrome('Monochrome'); + + final String value; + const NestedCommentIndicatorColor(this.value); + + factory NestedCommentIndicatorColor.fromJson(String value) => values.firstWhere((e) => e.value == value); + + String toJson() => value; + + @override + String toString() => value; +} diff --git a/lib/src/foundation/primitives/enums/post_body_view_type.dart b/lib/src/foundation/primitives/enums/post_body_view_type.dart index 899876c4f..ba4dd05c8 100644 --- a/lib/src/foundation/primitives/enums/post_body_view_type.dart +++ b/lib/src/foundation/primitives/enums/post_body_view_type.dart @@ -1,4 +1,4 @@ -enum PostBodyViewType { - condensed, - expanded, -} +enum PostBodyViewType { + condensed, + expanded, +} diff --git a/lib/src/foundation/utils/cache/image_cache_utils.dart b/lib/src/foundation/utils/cache/image_cache_utils.dart index 880ff0721..3fc1e1fbd 100644 --- a/lib/src/foundation/utils/cache/image_cache_utils.dart +++ b/lib/src/foundation/utils/cache/image_cache_utils.dart @@ -1,42 +1,42 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; - -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:extended_image/extended_image.dart'; - -/// Returns the total size of the image cache from ExtendedImage -Future getExtendedImageCacheSize() async { - try { - if (kIsWeb) return 0; - final Directory cacheImagesDirectory = Directory(join((await getTemporaryDirectory()).path, cacheImageFolderName)); - if (!cacheImagesDirectory.existsSync()) return 0; - - int totalSize = 0; - - // Iterate over the files in the directory - await for (final FileSystemEntity file in cacheImagesDirectory.list()) { - try { - final FileStat fs = file.statSync(); - totalSize += fs.size; - } catch (e) { - // Ignore errors - } - } - - return totalSize; - } catch (e) { - return -1; // Return -1 if an error occurs - } -} - -/// Clears the image cache from ExtendedImage, by deleting all files older than [duration]. -/// If [duration] is not provided, it defaults to 7 days. -Future clearExtendedImageCache({Duration expiration = const Duration(days: 7)}) async { - if (kIsWeb) return; - final Directory cacheImagesDirectory = Directory(join((await getTemporaryDirectory()).path, cacheImageFolderName)); - if (!cacheImagesDirectory.existsSync()) return; - - await clearDiskCachedImages(duration: expiration); -} +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:extended_image/extended_image.dart'; + +/// Returns the total size of the image cache from ExtendedImage +Future getExtendedImageCacheSize() async { + try { + if (kIsWeb) return 0; + final Directory cacheImagesDirectory = Directory(join((await getTemporaryDirectory()).path, cacheImageFolderName)); + if (!cacheImagesDirectory.existsSync()) return 0; + + int totalSize = 0; + + // Iterate over the files in the directory + await for (final FileSystemEntity file in cacheImagesDirectory.list()) { + try { + final FileStat fs = file.statSync(); + totalSize += fs.size; + } catch (e) { + // Ignore errors + } + } + + return totalSize; + } catch (e) { + return -1; // Return -1 if an error occurs + } +} + +/// Clears the image cache from ExtendedImage, by deleting all files older than [duration]. +/// If [duration] is not provided, it defaults to 7 days. +Future clearExtendedImageCache({Duration expiration = const Duration(days: 7)}) async { + if (kIsWeb) return; + final Directory cacheImagesDirectory = Directory(join((await getTemporaryDirectory()).path, cacheImageFolderName)); + if (!cacheImagesDirectory.existsSync()) return; + + await clearDiskCachedImages(duration: expiration); +} diff --git a/lib/src/shared/avatars/community_avatar.dart b/lib/src/shared/avatars/community_avatar.dart index 9401f4d0e..15b751b2e 100644 --- a/lib/src/shared/avatars/community_avatar.dart +++ b/lib/src/shared/avatars/community_avatar.dart @@ -33,8 +33,8 @@ class CommunityAvatar extends StatelessWidget { return Stack( children: [ - Avatar( - data: AvatarData( + ThunderAvatar( + data: ThunderAvatarData( fallbackLabel: community.titleOrName, imageUrl: imageUrl, radius: radius, diff --git a/lib/src/shared/avatars/instance_avatar.dart b/lib/src/shared/avatars/instance_avatar.dart index 8442dd15a..8bb0283ad 100644 --- a/lib/src/shared/avatars/instance_avatar.dart +++ b/lib/src/shared/avatars/instance_avatar.dart @@ -21,8 +21,8 @@ class InstanceAvatar extends StatelessWidget { ? instance.domain : ''; - return Avatar( - data: AvatarData( + return ThunderAvatar( + data: ThunderAvatarData( fallbackLabel: fallbackLabel, imageUrl: instance.icon, radius: radius, diff --git a/lib/src/shared/avatars/user_avatar.dart b/lib/src/shared/avatars/user_avatar.dart index c5f54ab13..1d227b9c0 100644 --- a/lib/src/shared/avatars/user_avatar.dart +++ b/lib/src/shared/avatars/user_avatar.dart @@ -24,8 +24,8 @@ class UserAvatar extends StatelessWidget { Widget build(BuildContext context) { final imageUrl = generateAvatarImageUrl(user.avatar, thumbnailSize: thumbnailSize, format: format); - return Avatar( - data: AvatarData( + return ThunderAvatar( + data: ThunderAvatarData( fallbackLabel: user.displayNameOrName, imageUrl: imageUrl, radius: radius, diff --git a/lib/src/shared/chips/user_chip.dart b/lib/src/shared/chips/user_chip.dart index 8d4c72ecb..f6c5fc382 100644 --- a/lib/src/shared/chips/user_chip.dart +++ b/lib/src/shared/chips/user_chip.dart @@ -139,7 +139,7 @@ class _UserChipGroups extends StatelessWidget { children: [ if (groups.contains(UserType.op)) Icon( - Thunder.microphone_variant, + ThunderIcon.microphone_variant, size: 15.0 * metadataFontSizeScale.textScaleFactor, color: iconColor, ), @@ -151,13 +151,13 @@ class _UserChipGroups extends StatelessWidget { ), if (groups.contains(UserType.admin)) Icon( - Thunder.shield_crown, + ThunderIcon.shield_crown, size: 14.0 * metadataFontSizeScale.textScaleFactor, color: iconColor, ), if (groups.contains(UserType.moderator)) Icon( - Thunder.shield, + ThunderIcon.shield, size: 14.0 * metadataFontSizeScale.textScaleFactor, color: iconColor, ), @@ -165,7 +165,7 @@ class _UserChipGroups extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Icon( - Thunder.robot, + ThunderIcon.robot, size: 12.0 * metadataFontSizeScale.textScaleFactor, color: iconColor, ), diff --git a/lib/src/shared/error_message.dart b/lib/src/shared/error_message.dart deleted file mode 100644 index 34631b5ba..000000000 --- a/lib/src/shared/error_message.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -class ErrorMessage extends StatelessWidget { - final String? title; - final String? message; - final List<({String text, void Function() action, bool loading})>? actions; - - const ErrorMessage({ - super.key, - this.title, - this.message, - this.actions, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Center( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.warning_rounded, size: 100, color: Colors.red.shade300), - const SizedBox(height: 32.0), - Text( - title ?? AppLocalizations.of(context)!.somethingWentWrong, - style: theme.textTheme.titleLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8.0), - Text( - message ?? AppLocalizations.of(context)!.missingErrorMessage, - style: theme.textTheme.labelLarge?.copyWith(color: theme.dividerColor), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32.0), - if (actions?.isNotEmpty == true) - Column( - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(50)), - onPressed: actions![0].loading == true ? null : () => actions![0].action.call(), - child: actions![0].loading == true - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(), - ) - : Text(actions![0].text), - ), - if (actions!.length > 1) ...[ - const SizedBox(height: 15), - Row( - children: [ - for (var action in actions!.skip(1)) ...[ - if (actions!.indexOf(action) > 1) const SizedBox(width: 10), - Expanded( - child: TextButton( - style: TextButton.styleFrom(minimumSize: const Size.fromHeight(50)), - onPressed: action.loading == true ? null : () => action.action.call(), - child: action.loading == true - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(), - ) - : Text(action.text), - ), - ), - ] - ], - ), - ], - ], - ) - ], - ), - ), - ); - } -} diff --git a/lib/src/shared/fabs/comment_navigator_fab.dart b/lib/src/shared/fabs/comment_navigator_fab.dart index 626de064a..279b4a972 100644 --- a/lib/src/shared/fabs/comment_navigator_fab.dart +++ b/lib/src/shared/fabs/comment_navigator_fab.dart @@ -1,248 +1,248 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:super_sliver_list/super_sliver_list.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/comment/api.dart'; -import 'package:thunder/src/features/settings/api.dart'; - -/// A FAB that allows the user to navigate up and down through a list of comments. -class CommentNavigatorFab extends StatefulWidget { - /// The [ScrollController] for the scrollable list - final ScrollController scrollController; - - /// The [ListController] for the scrollable list. This is used to navigate up and down - final ListController listController; - - /// The list of comments. This is used to determine the current and next parent - final List? comments; - - /// The initial index - final int initialIndex; - - /// The maximum index that can be scrolled to - final int maxIndex; - - /// The height of the OS status bar, needed to calculate an offset for scrolling comments to the top - final double statusBarHeight; - - const CommentNavigatorFab({ - super.key, - this.initialIndex = 0, - this.maxIndex = 0, - required this.scrollController, - required this.listController, - this.comments, - required this.statusBarHeight, - }); - - @override - State createState() => _CommentNavigatorFabState(); -} - -class _CommentNavigatorFabState extends State { - int currentIndex = 0; - - @override - void initState() { - super.initState(); - - currentIndex = widget.initialIndex; - } - - @override - void didUpdateWidget(covariant CommentNavigatorFab oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.initialIndex != widget.initialIndex) { - // Reset the index if it ever changes. This could be due to an external event - currentIndex = widget.initialIndex; - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final darkTheme = context.select((cubit) => cubit.state.useDarkTheme); - - return SizedBox( - width: 135, - child: Material( - color: Colors.transparent, - elevation: 3, - borderRadius: BorderRadius.circular(50), - child: Stack( - children: [ - Positioned.fill( - child: Align( - child: SizedBox( - height: 45, - child: Material( - color: darkTheme ? theme.colorScheme.primaryContainer : null, - borderRadius: BorderRadius.circular(50), - child: const InkWell(), - ), - ), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 45, - height: 45, - child: Material( - clipBehavior: Clip.antiAlias, - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: navigateToParent, - onLongPress: navigateUp, - child: Icon( - Icons.keyboard_arrow_up_rounded, - semanticLabel: AppLocalizations.of(context)!.navigateUp, - ), - ), - ), - ), - const SizedBox(width: 45), - SizedBox( - width: 45, - height: 45, - child: Material( - clipBehavior: Clip.antiAlias, - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: navigateToNextParent, - onLongPress: navigateDown, - child: Icon( - Icons.keyboard_arrow_down_rounded, - semanticLabel: AppLocalizations.of(context)!.navigateDown, - ), - ), - ), - ), - ], - ), - ], - ), - ), - ); - } - - void navigateUp() { - var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; - - int previousIndex = (unobstructedVisibleRange?.$1 ?? 0) - 1; - if (currentIndex == previousIndex) previousIndex--; - if (previousIndex < 0) previousIndex = 0; - - setState(() => currentIndex = previousIndex); - - // Calculate alignment - final double screenHeight = MediaQuery.of(context).size.height; - final double alignmentOffset = widget.statusBarHeight / screenHeight; - - widget.listController.animateToItem( - index: previousIndex, - scrollController: widget.scrollController, - alignment: alignmentOffset, - duration: (estimatedDistance) => const Duration(milliseconds: 450), - curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, - ); - } - - void navigateToParent() { - var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; - - int previousIndex = (unobstructedVisibleRange?.$1 ?? 0) - 1; - if (currentIndex == previousIndex) previousIndex--; - if (previousIndex < 0) previousIndex = 0; - - int parentCommentIndex = 0; - - for (int i = previousIndex; i >= 0; i--) { - CommentNode currentComment = widget.comments![i - 1]; - - List pathSegments = currentComment.comment!.path.split('.'); - int depth = pathSegments.length > 2 ? pathSegments.length - 2 : 0; - - if (depth == 0) { - parentCommentIndex = i; - break; - } - } - - setState(() => currentIndex = parentCommentIndex); - - // Calculate alignment - final double screenHeight = MediaQuery.of(context).size.height; - final double alignmentOffset = widget.statusBarHeight / screenHeight; - - widget.listController.animateToItem( - index: parentCommentIndex, - scrollController: widget.scrollController, - alignment: alignmentOffset, - duration: (estimatedDistance) => const Duration(milliseconds: 450), - curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, - ); - } - - void navigateDown() { - var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; - - int nextIndex = (unobstructedVisibleRange?.$1 ?? 0) + 1; - if (currentIndex == nextIndex) nextIndex++; - - setState(() => currentIndex = nextIndex); - - // Calculate alignment - final double screenHeight = MediaQuery.of(context).size.height; - final double alignmentOffset = widget.statusBarHeight / screenHeight; - - widget.listController.animateToItem( - index: nextIndex, - scrollController: widget.scrollController, - alignment: alignmentOffset, - duration: (estimatedDistance) => const Duration(milliseconds: 450), - curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, - ); - } - - void navigateToNextParent() { - var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; - - int nextIndex = (unobstructedVisibleRange?.$1 ?? 0) + 1; - if (currentIndex == nextIndex) nextIndex++; - - int parentCommentIndex = 0; - - for (int i = nextIndex; i < widget.comments!.length; i++) { - CommentNode currentComment = widget.comments![i - 1]; - - List pathSegments = currentComment.comment!.path.split('.'); - int depth = pathSegments.length > 2 ? pathSegments.length - 2 : 0; - - if (depth == 0) { - parentCommentIndex = i; - break; - } - } - - setState(() => currentIndex = parentCommentIndex); - - // Calculate alignment - final double screenHeight = MediaQuery.of(context).size.height; - final double alignmentOffset = widget.statusBarHeight / screenHeight; - - widget.listController.animateToItem( - index: parentCommentIndex, - scrollController: widget.scrollController, - alignment: alignmentOffset, - duration: (estimatedDistance) => const Duration(milliseconds: 450), - curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; + +/// A FAB that allows the user to navigate up and down through a list of comments. +class CommentNavigatorFab extends StatefulWidget { + /// The [ScrollController] for the scrollable list + final ScrollController scrollController; + + /// The [ListController] for the scrollable list. This is used to navigate up and down + final ListController listController; + + /// The list of comments. This is used to determine the current and next parent + final List? comments; + + /// The initial index + final int initialIndex; + + /// The maximum index that can be scrolled to + final int maxIndex; + + /// The height of the OS status bar, needed to calculate an offset for scrolling comments to the top + final double statusBarHeight; + + const CommentNavigatorFab({ + super.key, + this.initialIndex = 0, + this.maxIndex = 0, + required this.scrollController, + required this.listController, + this.comments, + required this.statusBarHeight, + }); + + @override + State createState() => _CommentNavigatorFabState(); +} + +class _CommentNavigatorFabState extends State { + int currentIndex = 0; + + @override + void initState() { + super.initState(); + + currentIndex = widget.initialIndex; + } + + @override + void didUpdateWidget(covariant CommentNavigatorFab oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.initialIndex != widget.initialIndex) { + // Reset the index if it ever changes. This could be due to an external event + currentIndex = widget.initialIndex; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final darkTheme = context.select((cubit) => cubit.state.useDarkTheme); + + return SizedBox( + width: 135, + child: Material( + color: Colors.transparent, + elevation: 3, + borderRadius: BorderRadius.circular(50), + child: Stack( + children: [ + Positioned.fill( + child: Align( + child: SizedBox( + height: 45, + child: Material( + color: darkTheme ? theme.colorScheme.primaryContainer : null, + borderRadius: BorderRadius.circular(50), + child: const InkWell(), + ), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 45, + height: 45, + child: Material( + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: navigateToParent, + onLongPress: navigateUp, + child: Icon( + Icons.keyboard_arrow_up_rounded, + semanticLabel: AppLocalizations.of(context)!.navigateUp, + ), + ), + ), + ), + const SizedBox(width: 45), + SizedBox( + width: 45, + height: 45, + child: Material( + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: navigateToNextParent, + onLongPress: navigateDown, + child: Icon( + Icons.keyboard_arrow_down_rounded, + semanticLabel: AppLocalizations.of(context)!.navigateDown, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void navigateUp() { + var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; + + int previousIndex = (unobstructedVisibleRange?.$1 ?? 0) - 1; + if (currentIndex == previousIndex) previousIndex--; + if (previousIndex < 0) previousIndex = 0; + + setState(() => currentIndex = previousIndex); + + // Calculate alignment + final double screenHeight = MediaQuery.of(context).size.height; + final double alignmentOffset = widget.statusBarHeight / screenHeight; + + widget.listController.animateToItem( + index: previousIndex, + scrollController: widget.scrollController, + alignment: alignmentOffset, + duration: (estimatedDistance) => const Duration(milliseconds: 450), + curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, + ); + } + + void navigateToParent() { + var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; + + int previousIndex = (unobstructedVisibleRange?.$1 ?? 0) - 1; + if (currentIndex == previousIndex) previousIndex--; + if (previousIndex < 0) previousIndex = 0; + + int parentCommentIndex = 0; + + for (int i = previousIndex; i >= 0; i--) { + CommentNode currentComment = widget.comments![i - 1]; + + List pathSegments = currentComment.comment!.path.split('.'); + int depth = pathSegments.length > 2 ? pathSegments.length - 2 : 0; + + if (depth == 0) { + parentCommentIndex = i; + break; + } + } + + setState(() => currentIndex = parentCommentIndex); + + // Calculate alignment + final double screenHeight = MediaQuery.of(context).size.height; + final double alignmentOffset = widget.statusBarHeight / screenHeight; + + widget.listController.animateToItem( + index: parentCommentIndex, + scrollController: widget.scrollController, + alignment: alignmentOffset, + duration: (estimatedDistance) => const Duration(milliseconds: 450), + curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, + ); + } + + void navigateDown() { + var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; + + int nextIndex = (unobstructedVisibleRange?.$1 ?? 0) + 1; + if (currentIndex == nextIndex) nextIndex++; + + setState(() => currentIndex = nextIndex); + + // Calculate alignment + final double screenHeight = MediaQuery.of(context).size.height; + final double alignmentOffset = widget.statusBarHeight / screenHeight; + + widget.listController.animateToItem( + index: nextIndex, + scrollController: widget.scrollController, + alignment: alignmentOffset, + duration: (estimatedDistance) => const Duration(milliseconds: 450), + curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, + ); + } + + void navigateToNextParent() { + var unobstructedVisibleRange = widget.listController.unobstructedVisibleRange; + + int nextIndex = (unobstructedVisibleRange?.$1 ?? 0) + 1; + if (currentIndex == nextIndex) nextIndex++; + + int parentCommentIndex = 0; + + for (int i = nextIndex; i < widget.comments!.length; i++) { + CommentNode currentComment = widget.comments![i - 1]; + + List pathSegments = currentComment.comment!.path.split('.'); + int depth = pathSegments.length > 2 ? pathSegments.length - 2 : 0; + + if (depth == 0) { + parentCommentIndex = i; + break; + } + } + + setState(() => currentIndex = parentCommentIndex); + + // Calculate alignment + final double screenHeight = MediaQuery.of(context).size.height; + final double alignmentOffset = widget.statusBarHeight / screenHeight; + + widget.listController.animateToItem( + index: parentCommentIndex, + scrollController: widget.scrollController, + alignment: alignmentOffset, + duration: (estimatedDistance) => const Duration(milliseconds: 450), + curve: (estimatedDistance) => Curves.easeInOutCubicEmphasized, + ); + } +} diff --git a/lib/src/shared/fabs/gesture_fab.dart b/lib/src/shared/fabs/gesture_fab.dart index de371a312..b4f72baa2 100644 --- a/lib/src/shared/fabs/gesture_fab.dart +++ b/lib/src/shared/fabs/gesture_fab.dart @@ -1,55 +1,15 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart'; import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; -import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/src/foundation/config/global_context.dart'; -/// Enum to distinguish between feed and post FABs +/// Distinguishes feed and post FAB chrome state. enum FabType { feed, post } -/// A FAB that allows the user to expand and collapse a list of actions. -class GestureFab extends StatefulWidget { - /// The distance between the FAB and the actions - final double distance; - - /// The list of actions to display - final List children; - - /// The icon to display on the FAB - final Icon icon; - - /// The function to call when the FAB is slid up - final Function? onSlideUp; - - /// The function to call when the FAB is slid left - final Function? onSlideLeft; - - /// The function to call when the FAB is slid down - final Function? onSlideDown; - - /// The function to call when the FAB is pressed - final Function? onPressed; - - /// The function to call when the FAB is long pressed - final Function? onLongPress; - - /// Whether the FAB is centered - final bool centered; - - /// The hero tag to use for the FAB - final String? heroTag; - - /// The background color of the FAB - final Color? fabBackgroundColor; - - /// The type of FAB (feed or post) - determines which state to use - final FabType fabType; - +/// Thin connector over [ThunderExpandableFab] wired to [ShellChromeCubit]. +class GestureFab extends StatelessWidget { const GestureFab({ super.key, required this.distance, @@ -66,67 +26,33 @@ class GestureFab extends StatefulWidget { this.fabType = FabType.feed, }); - @override - State createState() => _GestureFabState(); -} - -class _GestureFabState extends State with SingleTickerProviderStateMixin { - /// The controller for the animation - late final AnimationController _controller; - - /// The animation for the expansion of the FAB - late final Animation _expandAnimation; - - /// The function to call when the FAB is toggled - late final Function(String val)? toggle; - - /// Whether the FAB is open - bool isFabOpen = false; - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - value: isFabOpen ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - vsync: this, - ); - - _expandAnimation = CurvedAnimation( - curve: Curves.fastOutSlowIn, - reverseCurve: Curves.easeOutQuad, - parent: _controller, - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } + final double distance; + final List children; + final Icon icon; + final VoidCallback? onSlideUp; + final VoidCallback? onSlideLeft; + final VoidCallback? onSlideDown; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + final bool centered; + final String? heroTag; + final Color? fabBackgroundColor; + final FabType fabType; - /// Gets the current isFabOpen state based on the fabType - bool _getIsFabOpen(ShellChromeState state) { - return widget.fabType == FabType.feed ? state.isFeedFabOpen : state.isPostFabOpen; - } + bool _isOpen(ShellChromeState state) => fabType == FabType.feed ? state.isFeedFabOpen : state.isPostFabOpen; - /// Sets the FAB open state based on the fabType - void _setFabOpen(BuildContext context, bool isOpen) { + void _setOpen(BuildContext context, bool isOpen) { final cubit = context.read(); - - if (widget.fabType == FabType.feed) { + if (fabType == FabType.feed) { cubit.setFeedFabOpen(isOpen); } else { cubit.setPostFabOpen(isOpen); } } - /// Sets the FAB summoned state based on the fabType - void _setFabSummoned(BuildContext context, bool isSummoned) { + void _setSummoned(BuildContext context, bool isSummoned) { final cubit = context.read(); - - if (widget.fabType == FabType.feed) { + if (fabType == FabType.feed) { cubit.setFeedFabSummoned(isSummoned); } else { cubit.setPostFabSummoned(isSummoned); @@ -135,163 +61,31 @@ class _GestureFabState extends State with SingleTickerProviderStateM @override Widget build(BuildContext context) { - return BlocConsumer( - listenWhen: (previous, current) => _getIsFabOpen(previous) != _getIsFabOpen(current), - listener: (context, state) { - final isOpen = _getIsFabOpen(state); - - if (isOpen) { - _controller.forward(); - } else { - _controller.reverse(); - } - - if (isFabOpen != isOpen) { - setState(() => isFabOpen = isOpen); - } - }, + return BlocBuilder( builder: (context, state) { - return SizedBox.expand( - child: Stack( - alignment: widget.centered ? Alignment.bottomCenter : Alignment.bottomRight, - clipBehavior: Clip.none, - children: [ - _buildTapToCloseFab(), - ..._buildExpandingActionButtons(), - _buildTapToOpenFab(), - ], - ), + return ThunderExpandableFab( + open: _isOpen(state), + distance: distance, + icon: icon, + centered: centered, + heroTag: heroTag, + fabBackgroundColor: fabBackgroundColor, + onOpenChanged: (open) => _setOpen(context, open), + onSlideUp: onSlideUp, + onSlideLeft: onSlideLeft, + onSlideDown: onSlideDown ?? + () { + if (!Navigator.of(context).canPop()) { + _setSummoned(context, false); + } + }, + onPressed: onPressed, + onLongPress: onLongPress, + children: children, ); }, ); } - - Widget _buildTapToCloseFab() { - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - return SizedBox( - width: widget.centered ? 45 : 56, - height: widget.centered ? 45 : 56, - child: AnimatedBuilder( - animation: _expandAnimation, - builder: (context, child) => child!, - child: FadeTransition( - opacity: _expandAnimation, - child: Center( - child: Material( - shape: widget.centered ? null : const CircleBorder(), - clipBehavior: widget.centered ? Clip.none : Clip.antiAlias, - color: widget.centered ? Colors.transparent : null, - elevation: widget.centered ? 0 : 4, - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () { - _setFabOpen(context, false); - }, - child: Padding( - padding: EdgeInsets.all(widget.centered ? 12 : 8), - child: Icon( - Icons.close, - size: widget.centered ? 20 : 25, - color: theme.textTheme.bodyMedium?.color, - semanticLabel: l10n.close, - ), - ), - ), - ), - ), - ), - ), - ); - } - - List _buildExpandingActionButtons() { - final children = []; - final count = widget.children.length; - - for (var i = 0, distance = widget.distance; i < count; i++, distance += widget.distance) { - children.add( - _ExpandingActionButton( - maxDistance: distance, - progress: _expandAnimation, - focus: isFabOpen && i == count - 1, - centered: widget.centered, - first: i == count - 1, - last: i == 0, - child: widget.children[i], - ), - ); - } - - return children; - } - - Widget _buildTapToOpenFab() { - return IgnorePointer( - ignoring: isFabOpen, - child: AnimatedContainer( - transformAlignment: Alignment.center, - transform: Matrix4.diagonal3Values( - isFabOpen ? 0.7 : 1.0, - isFabOpen ? 0.7 : 1.0, - 1.0, - ), - duration: const Duration(milliseconds: 250), - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), - child: AnimatedOpacity( - opacity: isFabOpen ? 0.0 : 1.0, - curve: const Interval(0.25, 1.0, curve: Curves.easeInOut), - duration: const Duration(milliseconds: 250), - child: GestureDetector( - onVerticalDragUpdate: (details) { - if (details.delta.dy < -5) { - _setFabOpen(context, true); - } - if (details.delta.dy > 5) { - // Only allow hiding fab when on the main feed, and not when opening a community on a new page - if (Navigator.of(context).canPop() == false) _setFabSummoned(context, false); - } - }, - onHorizontalDragStart: null, - onLongPress: () { - HapticFeedback.heavyImpact(); - widget.onLongPress?.call(); - }, - onTapDown: (details) => HapticFeedback.mediumImpact(), - child: widget.centered - ? SizedBox( - width: 45, - height: 45, - child: Material( - shape: widget.centered ? null : const CircleBorder(), - clipBehavior: Clip.antiAlias, - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () { - HapticFeedback.mediumImpact(); - widget.onPressed?.call(); - }, - child: Icon( - widget.icon.icon, - size: 20, - semanticLabel: widget.icon.semanticLabel, - ), - ), - ), - ) - : FloatingActionButton( - heroTag: widget.heroTag, - backgroundColor: widget.fabBackgroundColor, - onPressed: () => widget.onPressed?.call(), - child: widget.icon, - ), - ), - ), - ), - ); - } } // ActionButton mutates first/last when placed inside expanding FAB stacks. @@ -314,11 +108,10 @@ class ActionButton extends StatelessWidget { final Color? backgroundColor; final FabType fabType; - bool? first; - bool? last; + bool first = false; + bool last = false; - /// Sets the FAB open state based on the fabType - void _setFabOpen(BuildContext context, bool isOpen) { + void _setOpen(BuildContext context, bool isOpen) { final cubit = context.read(); if (fabType == FabType.feed) { cubit.setFeedFabOpen(isOpen); @@ -329,172 +122,15 @@ class ActionButton extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final bool darkTheme = context.read().state.useDarkTheme; - - return centered - ? SizedBox( - width: 160, - child: Material( - color: Colors.transparent, - elevation: 3, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(first == true ? 20 : 0), - topRight: Radius.circular(first == true ? 20 : 0), - bottomLeft: Radius.circular(last == true ? 20 : 0), - bottomRight: Radius.circular(last == true ? 20 : 0), - ), - child: Stack( - children: [ - Positioned.fill( - child: Align( - child: SizedBox( - height: 40, - child: Material( - color: darkTheme ? theme.colorScheme.primaryContainer : null, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(first == true ? 20 : 0), - topRight: Radius.circular(first == true ? 20 : 0), - bottomLeft: Radius.circular(last == true ? 20 : 0), - bottomRight: Radius.circular(last == true ? 20 : 0), - ), - child: InkWell( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(first == true ? 20 : 0), - topRight: Radius.circular(first == true ? 20 : 0), - bottomLeft: Radius.circular(last == true ? 20 : 0), - bottomRight: Radius.circular(last == true ? 20 : 0), - ), - onTap: () { - _setFabOpen(context, false); - onPressed?.call(); - }, - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 5, right: 5), - child: IgnorePointer( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 10, right: 5), - child: Icon( - icon.icon, - size: 20, - ), - ), - const SizedBox( - height: 40, - ), - Flexible( - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 5), - child: title != null - ? Text( - title!, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ) - : Container(), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ) - : Row( - children: [ - title != null ? Text(title!) : Container(), - const SizedBox(width: 16), - SizedBox( - height: 40, - width: 40, - child: Material( - borderRadius: const BorderRadius.all(Radius.circular(8)), - clipBehavior: Clip.antiAlias, - color: backgroundColor ?? theme.colorScheme.primaryContainer, - elevation: 4, - child: InkWell( - onTap: () { - _setFabOpen(context, false); - onPressed?.call(); - }, - child: icon, - ), - ), - ), - ], - ); - } -} - -@immutable -class _ExpandingActionButton extends StatefulWidget { - const _ExpandingActionButton({ - required this.maxDistance, - required this.progress, - required this.child, - required this.focus, - this.centered = false, - required this.first, - required this.last, - }); - - final double maxDistance; - final Animation progress; - final Widget child; - final bool focus; - final bool centered; - - final bool first; - final bool last; - - @override - State<_ExpandingActionButton> createState() => _ExpandingActionButtonState(); -} - -class _ExpandingActionButtonState extends State<_ExpandingActionButton> { - bool _visible = false; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.progress, - builder: (context, child) { - final offset = Offset.fromDirection( - 90 * (math.pi / 180.0), - widget.progress.value * widget.maxDistance, - ); - _visible = !widget.progress.isDismissed; - return Visibility( - visible: _visible, - child: Positioned( - right: widget.centered ? null : 8.0 + offset.dx, - bottom: (widget.centered ? 15.0 : 10.0) + offset.dy, - child: Semantics( - focused: widget.focus, - child: child is FadeTransition && child.child is ActionButton - ? () { - (child.child as ActionButton).first = widget.first; - (child.child as ActionButton).last = widget.last; - return child; - }() - : child!, - ), - ), - ); + return ThunderFabActionButton( + onPressed: () { + _setOpen(context, false); + onPressed?.call(); }, - child: FadeTransition( - opacity: widget.progress, - child: widget.child, - ), + icon: icon, + label: title, + backgroundColor: backgroundColor, + compact: centered, ); } } diff --git a/lib/src/shared/input_dialogs.dart b/lib/src/shared/input_dialogs.dart index 944e3c87b..f7cbecbd1 100644 --- a/lib/src/shared/input_dialogs.dart +++ b/lib/src/shared/input_dialogs.dart @@ -15,12 +15,11 @@ import 'package:thunder/src/features/account/data/cache/profile_site_info_cache. import 'package:thunder/src/shared/avatars/community_avatar.dart'; import 'package:thunder/src/shared/avatars/user_avatar.dart'; import 'package:thunder/src/shared/name/full_name_widgets.dart'; -import 'package:thunder/src/shared/marquee_widget.dart'; import 'package:thunder/src/features/user/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/src/foundation/utils/utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show showThunderTypeaheadDialog; +import 'package:thunder/packages/ui/ui.dart'; /// Shows a dialog which allows typing/search for a user void showUserInputDialog( @@ -105,7 +104,7 @@ Widget buildUserSuggestionWidget(BuildContext context, ThunderUser payload, {voi title: Text(payload.displayNameOrName, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Semantics( excludeSemantics: true, - child: Marquee( + child: ThunderMarquee( animationDuration: const Duration(seconds: 2), backDuration: const Duration(seconds: 2), pauseDuration: const Duration(seconds: 1), @@ -226,7 +225,7 @@ Widget buildCommunitySuggestionWidget(BuildContext context, ThunderCommunity pay child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Marquee( + ThunderMarquee( animationDuration: const Duration(seconds: 2), backDuration: const Duration(seconds: 2), pauseDuration: const Duration(seconds: 1), diff --git a/lib/src/shared/links/link_bottom_sheet.dart b/lib/src/shared/links/link_bottom_sheet.dart index 2c1f6bbe1..9603e761a 100644 --- a/lib/src/shared/links/link_bottom_sheet.dart +++ b/lib/src/shared/links/link_bottom_sheet.dart @@ -315,17 +315,17 @@ class _LinkBottomSheetState extends State { ), const SizedBox(height: 10), if ((page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ - PickerItem( + ThunderPickerItem( label: l10n.open, icon: Icons.language, onSelected: () => handleLinkTap(context, widget.text, widget.url), ), - PickerItem( + ThunderPickerItem( label: l10n.copy, icon: Icons.copy_rounded, onSelected: () => Clipboard.setData(ClipboardData(text: widget.url ?? widget.text)), ), - PickerItem( + ThunderPickerItem( label: l10n.share, icon: Icons.share_rounded, onSelected: () => SharePlus.instance.share(ShareParams( @@ -333,7 +333,7 @@ class _LinkBottomSheetState extends State { sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), )), ), - PickerItem( + ThunderPickerItem( label: l10n.alternateSources, icon: Icons.link_rounded, onSelected: () => setState(() => page = LinkBottomSheetPage.alternateLinks), @@ -342,7 +342,7 @@ class _LinkBottomSheetState extends State { ], if ((page ?? widget.initialPage) == LinkBottomSheetPage.alternateLinks) ...generateAlternateSources(widget.url ?? widget.text).map((alternateSource) { - return PickerItem( + return ThunderPickerItem( label: alternateSource.sourceName, subtitle: alternateSource.link, icon: Icons.archive_rounded, diff --git a/lib/src/shared/marquee_widget.dart b/lib/src/shared/marquee_widget.dart deleted file mode 100644 index e475ccc3e..000000000 --- a/lib/src/shared/marquee_widget.dart +++ /dev/null @@ -1,70 +0,0 @@ -library; - -import 'package:flutter/material.dart'; - -enum DirectionMarguee { oneDirection, twoDirection } - -class Marquee extends StatelessWidget { - final Widget child; - final TextDirection textDirection; - final Axis direction; - final Duration animationDuration, backDuration, pauseDuration; - final DirectionMarguee directionMarguee; - final Cubic forwardAnimation; - final Cubic backwardAnimation; - final bool autoRepeat; - Marquee({ - super.key, - required this.child, - this.direction = Axis.horizontal, - this.textDirection = TextDirection.ltr, - this.animationDuration = const Duration(milliseconds: 5000), - this.backDuration = const Duration(milliseconds: 5000), - this.pauseDuration = const Duration(milliseconds: 2000), - this.directionMarguee = DirectionMarguee.twoDirection, - this.forwardAnimation = Curves.easeIn, - this.backwardAnimation = Curves.easeOut, - this.autoRepeat = true, - }); - - final ScrollController _scrollController = ScrollController(); - - void scroll(bool repeated) async { - do { - if (_scrollController.hasClients) { - await Future.delayed(pauseDuration); - if (_scrollController.hasClients) await _scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: animationDuration, curve: forwardAnimation); - await Future.delayed(pauseDuration); - if (_scrollController.hasClients) { - switch (directionMarguee) { - case DirectionMarguee.oneDirection: - _scrollController.jumpTo( - 0.0, - ); - break; - case DirectionMarguee.twoDirection: - await _scrollController.animateTo(0.0, duration: backDuration, curve: backwardAnimation); - break; - } - } - repeated = autoRepeat; - } else { - await Future.delayed(pauseDuration); - } - } while (repeated); - } - - @override - Widget build(BuildContext context) { - bool repeated = true; - scroll(repeated); - return Directionality( - textDirection: textDirection, - child: SingleChildScrollView( - scrollDirection: direction, - controller: _scrollController, - child: child, - ), - ); - } -} diff --git a/lib/src/shared/media/experimental_image_viewer.dart b/lib/src/shared/media/experimental_image_viewer.dart index 5478da1c1..be9f24c7b 100644 --- a/lib/src/shared/media/experimental_image_viewer.dart +++ b/lib/src/shared/media/experimental_image_viewer.dart @@ -10,8 +10,8 @@ import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderImageViewer, ThunderImageViewerSource, showSnackbar; import 'package:thunder/src/shared/media/media_utils.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// An experimental Thunder-specific image viewer built on top of /// [ThunderImageViewer]. @@ -224,7 +224,7 @@ class _ExperimentalImageViewerState extends State { sharePositionOrigin: const Rect.fromLTWH(0, 0, 1, 1), )); } catch (e) { - showSnackbar(l10n.errorDownloadingMedia(e)); + showThunderSnackbar(l10n.errorDownloadingMedia(e)); } finally { if (mounted) { setState(() => _isDownloadingMedia = false); @@ -264,12 +264,12 @@ class _ExperimentalImageViewerState extends State { setState(() => _downloaded = true); } on GalException catch (e) { if (mounted) { - showSnackbar(e.type.message); + showThunderSnackbar(e.type.message); setState(() => _downloaded = false); } } catch (e) { if (mounted) { - showSnackbar(l10n.errorDownloadingMedia(e)); + showThunderSnackbar(l10n.errorDownloadingMedia(e)); } } finally { if (mounted) { diff --git a/lib/src/shared/media/image_preview.dart b/lib/src/shared/media/image_preview.dart index 36f576e91..b5e606150 100644 --- a/lib/src/shared/media/image_preview.dart +++ b/lib/src/shared/media/image_preview.dart @@ -8,6 +8,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_avif/flutter_avif.dart'; import 'package:thunder/src/shared/media/media_utils.dart'; +import 'package:thunder/packages/ui/ui.dart'; /// The loading state of an image preview. enum ImagePreviewState { @@ -373,30 +374,13 @@ class ImagePreviewError extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - if (blur) return const SizedBox.shrink(); - - final iconColor = theme.colorScheme.onSecondaryContainer.withValues(alpha: viewed ? 0.55 : 1.0); - - if (canRetry && onRetry != null) { - return GestureDetector( - onTap: onRetry, - behavior: HitTestBehavior.opaque, - child: Center( - child: Tooltip( - message: retryTooltip, - child: Icon(Icons.refresh_rounded, color: iconColor), - ), - ), - ); - } - - return Center( - child: Icon( - _getErrorIcon(mediaType), - color: iconColor, - ), + return ThunderMediaPreviewError( + icon: _getErrorIcon(mediaType), + blur: blur, + viewed: viewed, + canRetry: canRetry, + onRetry: onRetry, + retryTooltip: retryTooltip, ); } } diff --git a/lib/src/shared/media/image_viewer.dart b/lib/src/shared/media/image_viewer.dart deleted file mode 100644 index 5b26f7b10..000000000 --- a/lib/src/shared/media/image_viewer.dart +++ /dev/null @@ -1,649 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:extended_image/extended_image.dart'; -import 'package:gal/gal.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; -import 'package:thunder/src/shared/media/media_utils.dart'; - -class ImageViewer extends StatefulWidget { - /// The URL of the image to display - final String? url; - - /// The bytes of the image to display - final Uint8List? bytes; - - /// The ID of the post to navigate to - final int? postId; - - /// The function to navigate to the post - final void Function()? navigateToPost; - - /// The alt text of the image - final String? altText; - - /// Whether this image viewer is being shown within the context of a peek. - final bool isPeek; - - /// Controls whether image cache should be aggressively cleared on dispose. - final bool clearMemoryCacheWhenDispose; - - const ImageViewer({ - super.key, - this.url, - this.bytes, - this.postId, - this.navigateToPost, - this.altText, - this.isPeek = false, - this.clearMemoryCacheWhenDispose = false, - }) : assert(url != null || bytes != null); - - @override - State createState() => _ImageViewerState(); -} - -class _ImageViewerState extends State with TickerProviderStateMixin { - final slidePagekey = GlobalKey(); - final gestureKey = GlobalKey(); - - bool downloaded = false; - double slideTransparency = 0.92; - double imageTransparency = 1.0; - bool maybeSlideZooming = false; - bool slideZooming = false; - bool fullscreen = false; - Offset downCoord = Offset.zero; - double delta = 0.0; - bool areImageDimensionsLoaded = false; - - /// User Settings - bool isUserLoggedIn = false; - bool isDownloadingMedia = false; - bool isSavingMedia = false; - late double imageWidth = 0; - late double imageHeight = 0; - late double maxZoomLevel = 3; - - /// Whether to show the alt text at the bottom of the image viewer - bool showAltText = false; - - void _maybeSlide(BuildContext context) { - setState(() => maybeSlideZooming = true); - Timer(const Duration(milliseconds: 500), () => context.mounted ? setState(() => maybeSlideZooming = false) : null); - } - - void enterFullScreen() { - setState(() => fullscreen = true); - if (fullscreen) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); - } - } - - void exitFullScreen() { - setState(() => fullscreen = false); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: SystemUiOverlay.values); - } - - Future getImageSize() async { - try { - Size decodedImage = await retrieveImageDimensions(imageUrl: widget.url, imageBytes: widget.bytes).timeout(const Duration(seconds: 2)); - - setState(() { - imageWidth = decodedImage.width; - imageHeight = decodedImage.height; - maxZoomLevel = max(imageWidth, imageHeight) / 128; - areImageDimensionsLoaded = true; - }); - } catch (e) { - debugPrint(e.toString()); - - setState(() { - maxZoomLevel = 3; - areImageDimensionsLoaded = true; - }); - } - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - getImageSize(); - }); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - - AnimationController animationController = AnimationController(duration: const Duration(milliseconds: 140), vsync: this); - Function() animationListener = () {}; - Animation? animation; - - return PopScope( - onPopInvokedWithResult: (didPop, result) { - if (didPop) { - exitFullScreen(); - } - }, - child: Stack( - children: [ - AppBar( - backgroundColor: Colors.transparent, - systemOverlayStyle: const SystemUiOverlayStyle( - // Forcing status bar to display bright icons even in light mode - statusBarIconBrightness: Brightness.light, // For Android (dark icons) - statusBarBrightness: Brightness.dark, // For iOS (dark icons) - ), - ), - AnimatedContainer( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOutCubicEmphasized, - color: fullscreen ? Colors.black : Colors.black.withValues(alpha: slideTransparency), - ), - Positioned.fill( - child: GestureDetector( - onLongPress: () { - HapticFeedback.lightImpact(); - if (fullscreen) { - exitFullScreen(); - } else { - enterFullScreen(); - } - }, - onTap: () { - if (!fullscreen) { - slidePagekey.currentState!.popPage(); - Navigator.pop(context); - } else { - exitFullScreen(); - } - }, - // Start doubletap zoom if conditions are met - onVerticalDragStart: maybeSlideZooming - ? (details) { - setState(() { - slideZooming = true; - }); - } - : null, - // Zoom image in an out based on movement in vertical axis if conditions are met - onVerticalDragUpdate: maybeSlideZooming || slideZooming - ? (details) { - // Need to catch the drag during "maybe" phase or it wont activate fast enough - if (slideZooming) { - double newScale = max(gestureKey.currentState!.gestureDetails!.totalScale! * (1 + (details.delta.dy / 150)), 1); - gestureKey.currentState?.handleDoubleTap(scale: newScale, doubleTapPosition: gestureKey.currentState!.pointerDownPosition); - } - } - : null, - // End doubletap zoom - onVerticalDragEnd: slideZooming - ? (details) { - setState(() { - slideZooming = false; - }); - } - : null, - child: Listener( - // Start watching for double tap zoom - onPointerDown: (details) { - downCoord = details.position; - }, - onPointerUp: (details) { - delta = (downCoord - details.position).distance; - if (!slideZooming && delta < 0.5) { - _maybeSlide(context); - } - }, - onPointerMove: (details) { - if (gestureKey.currentState!.gestureDetails!.totalScale! > 1.2) { - enterFullScreen(); - } else { - exitFullScreen(); - } - }, - child: ExtendedImageSlidePage( - key: slidePagekey, - slideAxis: SlideAxis.both, - slideType: SlideType.onlyImage, - slidePageBackgroundHandler: (offset, pageSize) { - return Colors.transparent; - }, - onSlidingPage: (state) { - // Fade out image and background when sliding to dismiss - var offset = state.offset; - var pageSize = state.pageSize; - - var scale = offset.distance / Offset(pageSize.width, pageSize.height).distance; - - if (state.isSliding) { - setState(() { - slideTransparency = 0.9 - min(0.9, scale * 0.5); - imageTransparency = 1.0 - min(1.0, scale * 10); - }); - } - }, - slideEndHandler: ( - // Decrease slide to dismiss threshold so it can be done easier - Offset offset, { - ExtendedImageSlidePageState? state, - ScaleEndDetails? details, - }) { - if (state != null) { - var offset = state.offset; - var pageSize = state.pageSize; - return offset.distance.greaterThan(Offset(pageSize.width, pageSize.height).distance / 10); - } - return true; - }, - child: widget.url != null - ? ExtendedImage.network( - widget.url!, - color: Colors.white.withValues(alpha: imageTransparency), - colorBlendMode: BlendMode.dstIn, - enableSlideOutPage: true, - mode: ExtendedImageMode.gesture, - extendedImageGestureKey: gestureKey, - cache: true, - clearMemoryCacheWhenDispose: widget.clearMemoryCacheWhenDispose, - layoutInsets: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 50, top: MediaQuery.of(context).padding.top + 50), - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - minScale: 0.8, - animationMinScale: 0.8, - maxScale: maxZoomLevel.toDouble(), - animationMaxScale: maxZoomLevel.toDouble(), - speed: 1.0, - inertialSpeed: 250.0, - initialScale: 1.0, - inPageView: false, - initialAlignment: InitialAlignment.center, - reverseMousePointerScrollDirection: true, - gestureDetailsIsChanged: (GestureDetails? details) {}, - ); - }, - onDoubleTap: (ExtendedImageGestureState state) { - var pointerDownPosition = state.pointerDownPosition; - double begin = state.gestureDetails!.totalScale!; - double end; - - animation?.removeListener(animationListener); - animationController.stop(); - animationController.reset(); - - if (begin == 1) { - end = 2; - enterFullScreen(); - } else if (begin > 1.99 && begin < 2.01) { - end = 4; - } else { - end = 1; - exitFullScreen(); - } - animationListener = () { - state.handleDoubleTap(scale: animation!.value, doubleTapPosition: pointerDownPosition); - }; - animation = animationController.drive(Tween(begin: begin, end: end)); - - animation!.addListener(animationListener); - - animationController.forward(); - }, - loadStateChanged: (state) { - if (state.extendedImageLoadState == LoadState.loading) { - return Center( - child: CircularProgressIndicator( - color: Colors.white.withValues(alpha: 0.90), - ), - ); - } - return null; - }, - ) - : ExtendedImage.memory( - widget.bytes!, - color: Colors.white.withValues(alpha: imageTransparency), - colorBlendMode: BlendMode.dstIn, - enableSlideOutPage: true, - mode: ExtendedImageMode.gesture, - extendedImageGestureKey: gestureKey, - clearMemoryCacheWhenDispose: widget.clearMemoryCacheWhenDispose, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - minScale: 0.8, - animationMinScale: 0.8, - maxScale: 4.0, - animationMaxScale: 4.0, - speed: 1.0, - inertialSpeed: 250.0, - initialScale: 1.0, - inPageView: false, - initialAlignment: InitialAlignment.center, - reverseMousePointerScrollDirection: true, - gestureDetailsIsChanged: (GestureDetails? details) {}, - ); - }, - onDoubleTap: (ExtendedImageGestureState state) { - var pointerDownPosition = state.pointerDownPosition; - double begin = state.gestureDetails!.totalScale!; - double end; - - animation?.removeListener(animationListener); - animationController.stop(); - animationController.reset(); - - if (begin == 1) { - end = 2; - enterFullScreen(); - } else if (begin > 1.99 && begin < 2.01) { - end = 4; - } else { - end = 1; - exitFullScreen(); - } - animationListener = () { - state.handleDoubleTap(scale: animation!.value, doubleTapPosition: pointerDownPosition); - }; - animation = animationController.drive(Tween(begin: begin, end: end)); - - animation!.addListener(animationListener); - - animationController.forward(); - }, - loadStateChanged: (state) { - if (state.extendedImageLoadState == LoadState.loading) { - return Center( - child: CircularProgressIndicator( - color: Colors.white.withValues(alpha: 0.90), - ), - ); - } - return null; - }, - ), - ), - ), - ), - ), - if (!widget.isPeek) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AnimatedOpacity( - opacity: fullscreen ? 0.0 : 1.0, - duration: const Duration(milliseconds: 200), - child: Container( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - stops: [0, 0.3, 1], - colors: [ - Colors.transparent, - Colors.black26, - Colors.black45, - ], - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - semanticLabel: "Back", - color: Colors.white.withValues(alpha: 0.90), - ), - ), - ), - ], - ), - ), - ), - const Spacer(), - AnimatedOpacity( - opacity: fullscreen ? 0.0 : 1.0, - duration: const Duration(milliseconds: 200), - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [0, 0.3, 1], - colors: [ - Colors.transparent, - Colors.black26, - Colors.black45, - ], - ), - ), - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (widget.url != null) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - tooltip: l10n.share, - onPressed: fullscreen - ? null - : () async { - try { - // Try to get the cached image first - var media = await DefaultCacheManager().getFileFromCache(widget.url!); - File? mediaFile = media?.file; - - if (media == null) { - setState(() => isDownloadingMedia = true); - - // Download - mediaFile = await DefaultCacheManager().getSingleFile(widget.url!); - } - - // Share - await SharePlus.instance.share(ShareParams( - files: [XFile(mediaFile!.path)], - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - } catch (e) { - // Tell the user that the download failed - showSnackbar(l10n.errorDownloadingMedia(e)); - } finally { - setState(() => isDownloadingMedia = false); - } - }, - icon: isDownloadingMedia - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white.withValues(alpha: 0.90), - ), - ) - : Icon( - Icons.share_rounded, - semanticLabel: l10n.share, - color: Colors.white.withValues(alpha: 0.90), - ), - ), - ), - if (widget.url != null) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - tooltip: l10n.save, - onPressed: (downloaded || isSavingMedia || fullscreen || widget.url == null || kIsWeb) - ? null - : () async { - File file = await DefaultCacheManager().getSingleFile(widget.url!); - bool hasPermission = await Gal.hasAccess(toAlbum: true); - if (!hasPermission) { - await Gal.requestAccess(toAlbum: true); - } - - setState(() => isSavingMedia = true); - - try { - // Save image on Linux platform - if (Platform.isLinux) { - final filePath = '${(await getApplicationDocumentsDirectory()).path}/Thunder/${basename(file.path)}'; - - File(filePath) - ..createSync(recursive: true) - ..writeAsBytesSync(file.readAsBytesSync()); - - return setState(() => downloaded = true); - } - - // Save image on all other supported platforms (Android, iOS, macOS, Windows) - try { - await Gal.putImage(file.path, album: "Thunder"); - setState(() => downloaded = true); - } on GalException catch (e) { - if (context.mounted) { - showSnackbar(e.type.message); - } - setState(() => downloaded = false); - } - } finally { - setState(() => isSavingMedia = false); - } - }, - icon: isSavingMedia - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white.withValues(alpha: 0.90), - ), - ) - : downloaded - ? Icon( - Icons.check_circle, - semanticLabel: 'Downloaded', - color: Colors.white.withValues(alpha: 0.90), - ) - : Icon( - Icons.download, - semanticLabel: "Download", - color: Colors.white.withValues(alpha: 0.90), - ), - ), - ), - if (widget.navigateToPost != null) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - tooltip: l10n.comments, - onPressed: () { - Navigator.pop(context); - widget.navigateToPost!(); - }, - icon: Icon( - Icons.chat_rounded, - semanticLabel: l10n.comments, - color: Colors.white.withValues(alpha: 0.90), - ), - ), - ), - if (widget.altText?.isNotEmpty == true) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - tooltip: l10n.altText, - onPressed: () => setState(() => showAltText = !showAltText), - icon: Icon( - Icons.text_fields, - semanticLabel: l10n.altText, - color: Colors.white.withValues(alpha: showAltText ? 0.90 : 0.5), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - tooltip: l10n.fullscreen, - onPressed: () { - if (fullscreen) { - exitFullScreen(); - } else { - enterFullScreen(); - } - }, - icon: Icon( - Icons.fullscreen, - semanticLabel: l10n.fullscreen, - color: Colors.white.withValues(alpha: 0.90), - ), - ), - ), - ], - ), - ), - ), - ], - ), - if (widget.altText?.isNotEmpty == true && showAltText) - Positioned( - bottom: kBottomNavigationBarHeight + 25, - width: MediaQuery.sizeOf(context).width, - child: AnimatedOpacity( - opacity: fullscreen ? 0.0 : 1.0, - duration: const Duration(milliseconds: 200), - child: Padding( - padding: const EdgeInsets.all(16), - child: ImageAltText(text: widget.altText!), - ), - ), - ), - ], - ), - ); - } -} - -class ImageAltText extends StatelessWidget { - /// The text to display - final String text; - - const ImageAltText({super.key, required this.text}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Text( - text, - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.white.withValues(alpha: 0.90), - shadows: [ - Shadow( - offset: const Offset(1, 1), - color: Colors.black.withValues(alpha: 1), - blurRadius: 5.0, - ) - ], - ), - ); - } -} diff --git a/lib/src/shared/media/media_utils.dart b/lib/src/shared/media/media_utils.dart index 3d7da1256..0d7d6454c 100644 --- a/lib/src/shared/media/media_utils.dart +++ b/lib/src/shared/media/media_utils.dart @@ -21,7 +21,6 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/utils/media_url_utils.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/shared/media/experimental_image_viewer.dart'; -import 'package:thunder/src/shared/media/image_viewer.dart'; final Map _imageDimensionsCache = {}; @@ -193,10 +192,6 @@ Future> selectImagesToUpload({bool allowMultiple = false}) async { return [file.path]; } -bool useExperimentalImageViewer(BuildContext context) { - return context.read().state.enableExperimentalFeatures; -} - Widget buildImageViewerWidget( BuildContext context, { String? altText, @@ -207,24 +202,12 @@ Widget buildImageViewerWidget( int? postId, String? url, }) { - if (useExperimentalImageViewer(context)) { - return ExperimentalImageViewer( - altText: altText, - bytes: bytes, - isPeek: isPeek, - navigateToPost: navigateToPost, - url: url, - ); - } - - return ImageViewer( - url: url, - bytes: bytes, - postId: postId, - navigateToPost: navigateToPost, + return ExperimentalImageViewer( altText: altText, + bytes: bytes, isPeek: isPeek, - clearMemoryCacheWhenDispose: clearMemoryCacheWhenDispose ?? false, + navigateToPost: navigateToPost, + url: url, ); } diff --git a/lib/src/shared/media/thunder_video_player.dart b/lib/src/shared/media/thunder_video_player.dart index 49a28d5a5..d662dcb2e 100644 --- a/lib/src/shared/media/thunder_video_player.dart +++ b/lib/src/shared/media/thunder_video_player.dart @@ -12,7 +12,7 @@ import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/app/state/network_checker_cubit/network_checker_cubit.dart'; import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; class ThunderVideoPlayer extends StatefulWidget { const ThunderVideoPlayer({ @@ -100,7 +100,7 @@ class _ThunderVideoPlayerState extends State { } if (_videoPlayerController.value.hasError) { - showSnackbar( + showThunderSnackbar( GlobalContext.l10n.failedToLoadVideo, trailingIcon: Icons.chevron_right_rounded, trailingAction: () { diff --git a/lib/src/shared/name/full_name_copy_utils.dart b/lib/src/shared/name/full_name_copy_utils.dart index 318a95fa7..568ae2ed2 100644 --- a/lib/src/shared/name/full_name_copy_utils.dart +++ b/lib/src/shared/name/full_name_copy_utils.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; enum ActivityPubFullNameType { user, @@ -59,5 +59,5 @@ Future copyActivityPubFullName({ HapticFeedback.mediumImpact(); await Clipboard.setData(ClipboardData(text: fullName)); - showSnackbar(GlobalContext.l10n.copiedToClipboard); + showThunderSnackbar(GlobalContext.l10n.copiedToClipboard); } diff --git a/lib/src/shared/reply_to_preview_actions.dart b/lib/src/shared/reply_to_preview_actions.dart deleted file mode 100644 index 59ade727c..000000000 --- a/lib/src/shared/reply_to_preview_actions.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderDivider, ThunderIconLabel, showSnackbar; - -/// Defines a widget which provides action buttons for the preview of a post or comment when replying -/// -/// For example, actions to view the original source or copy the text to the clipboard. -class ReplyToPreviewActions extends StatelessWidget { - /// The text to be copied to the clipboard. - final String text; - - /// Whether to show the source text or the markdown text. - final bool viewSource; - - /// Whether the view source is toggled or not. - final void Function()? onViewSourceToggled; - - const ReplyToPreviewActions({ - super.key, - required this.text, - required this.viewSource, - required this.onViewSourceToggled, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - - return Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), - child: Material( - color: Colors.transparent, - child: Column( - children: [ - const ThunderDivider(sliver: false, padding: false), - Row( - spacing: 12.0, - children: [ - InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onViewSourceToggled, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), - child: ThunderIconLabel( - gap: 5.0, - icon: Icon(Icons.edit_document, size: 15.0), - label: viewSource ? l10n.viewOriginal : l10n.viewSource, - ), - ), - ), - InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () async { - await Clipboard.setData(ClipboardData(text: text)); - showSnackbar(l10n.copiedToClipboard); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), - child: ThunderIconLabel( - gap: 5.0, - icon: Icon(Icons.copy_rounded, size: 15.0), - label: l10n.copyText, - ), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/shared/share/share_action_bottom_sheet.dart b/lib/src/shared/share/share_action_bottom_sheet.dart index 84b97afec..679930e35 100644 --- a/lib/src/shared/share/share_action_bottom_sheet.dart +++ b/lib/src/shared/share/share_action_bottom_sheet.dart @@ -6,11 +6,12 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:share_plus/share_plus.dart'; import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/foundation/networking/instance_uri.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/post/api.dart'; import 'package:thunder/src/shared/share/advanced_share_sheet/advanced_share_sheet.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, showSnackbar; +import 'package:thunder/packages/ui/ui.dart'; /// Defines the actions that can be taken on a post when sharing enum ShareBottomSheetAction { @@ -101,12 +102,12 @@ class ShareActionBottomSheet extends StatefulWidget { class _ShareActionBottomSheetState extends State { String generateCommentUrl(int commentId) { final account = widget.account; - return 'https://${account.instance}/comment/$commentId'; + return buildInstanceUrl(account.instance, '/comment/$commentId'); } String generatePostUrl(int postId) { final account = widget.account; - return 'https://${account.instance}/post/$postId'; + return buildInstanceUrl(account.instance, '/post/$postId'); } void retrieveMedia(String? url) async { @@ -120,7 +121,7 @@ class _ShareActionBottomSheetState extends State { File? mediaFile = media?.file; if (media == null) { - showSnackbar(l10n.downloadingMedia); + showThunderSnackbar(l10n.downloadingMedia); mediaFile = await DefaultCacheManager().getSingleFile(url); } @@ -129,7 +130,7 @@ class _ShareActionBottomSheetState extends State { sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), )); } catch (e) { - showSnackbar(l10n.errorDownloadingMedia(e)); + showThunderSnackbar(l10n.errorDownloadingMedia(e)); } } @@ -253,7 +254,7 @@ class _ShareActionBottomSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ ...userActions.map( - (sharePostAction) => BottomSheetAction( + (sharePostAction) => ThunderBottomSheetAction( title: sharePostAction.name, subtitle: generateSubtitle(sharePostAction), leading: Icon(sharePostAction.icon), diff --git a/lib/src/shared/sort_picker.dart b/lib/src/shared/sort_picker.dart index 362284a60..4e13d24d1 100644 --- a/lib/src/shared/sort_picker.dart +++ b/lib/src/shared/sort_picker.dart @@ -3,69 +3,69 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/account/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, PickerItem; +import 'package:thunder/packages/ui/ui.dart'; // ============================================================================ // Post Sort Type Items // ============================================================================ /// Returns the "Top" sort type items for posts (TopHour, TopDay, etc.) -List> getTopPostSortTypeItems({Account? account}) { +List> getTopPostSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; - List> topPostSortTypeItems = [ - ListPickerItem( + List> topPostSortTypeItems = [ + ThunderListPickerItem( payload: PostSortType.topHour, icon: Icons.check_box_outline_blank, label: l10n.topHour, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topSixHour, icon: Icons.calendar_view_month, label: l10n.topSixHour, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topTwelveHour, icon: Icons.calendar_view_week, label: l10n.topTwelveHour, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topDay, icon: Icons.today, label: l10n.topDay, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topWeek, icon: Icons.view_week_sharp, label: l10n.topWeek, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topMonth, icon: Icons.calendar_month, label: l10n.topMonth, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topThreeMonths, icon: Icons.calendar_month_outlined, label: l10n.topThreeMonths, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topSixMonths, icon: Icons.calendar_today_outlined, label: l10n.topSixMonths, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topNineMonths, icon: Icons.calendar_view_day_outlined, label: l10n.topNineMonths, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topYear, icon: Icons.calendar_today, label: l10n.topYear, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.topAll, icon: Icons.military_tech, label: l10n.topAll, @@ -79,47 +79,47 @@ List> getTopPostSortTypeItems({Account? account}) { } /// Returns the default (non-Top) sort type items for posts -List> getDefaultPostSortTypeItems({Account? account}) { +List> getDefaultPostSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; - List> defaultPostSortTypeItems = [ - ListPickerItem( + List> defaultPostSortTypeItems = [ + ThunderListPickerItem( payload: PostSortType.hot, icon: Icons.local_fire_department_rounded, label: l10n.hot, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.active, icon: Icons.rocket_launch_rounded, label: l10n.active, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.scaled, icon: Icons.line_weight_rounded, label: l10n.scaled, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.controversial, icon: Icons.warning_rounded, label: l10n.controversial, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.new_, icon: Icons.auto_awesome_rounded, label: l10n.new_, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.old, icon: Icons.access_time_outlined, label: l10n.old, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.mostComments, icon: Icons.comment_bank_rounded, label: l10n.mostComments, ), - ListPickerItem( + ThunderListPickerItem( payload: PostSortType.newComments, icon: Icons.add_comment_rounded, label: l10n.newComments, @@ -133,39 +133,39 @@ List> getDefaultPostSortTypeItems({Account? account } /// All post sort type items (default + top) combined. -List> allPostSortTypeItems = [...getDefaultPostSortTypeItems(), ...getTopPostSortTypeItems()]; +List> allPostSortTypeItems = [...getDefaultPostSortTypeItems(), ...getTopPostSortTypeItems()]; // ============================================================================ // Comment Sort Type Items // ============================================================================ /// Returns the sort type items for comments -List> getCommentSortTypeItems({Account? account}) { +List> getCommentSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; - List> commentSortTypeItems = [ - ListPickerItem( + List> commentSortTypeItems = [ + ThunderListPickerItem( payload: CommentSortType.hot, icon: Icons.local_fire_department, label: l10n.hot, ), - ListPickerItem( + ThunderListPickerItem( payload: CommentSortType.top, icon: Icons.military_tech, label: l10n.top, ), - ListPickerItem( + ThunderListPickerItem( payload: CommentSortType.controversial, icon: Icons.warning_rounded, label: l10n.controversial, ), - ListPickerItem( + ThunderListPickerItem( payload: CommentSortType.new_, icon: Icons.auto_awesome_rounded, label: l10n.new_, ), - ListPickerItem( + ThunderListPickerItem( payload: CommentSortType.old, icon: Icons.access_time_outlined, label: l10n.old, @@ -183,62 +183,62 @@ List> getCommentSortTypeItems({Account? account} // ============================================================================ /// Returns the "Top" sort type items for search (TopHour, TopDay, etc.) -List> getTopSearchSortTypeItems({Account? account}) { +List> getTopSearchSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; - List> topSearchSortTypeItems = [ - ListPickerItem( + List> topSearchSortTypeItems = [ + ThunderListPickerItem( payload: SearchSortType.topHour, icon: Icons.check_box_outline_blank, label: l10n.topHour, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topSixHour, icon: Icons.calendar_view_month, label: l10n.topSixHour, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topTwelveHour, icon: Icons.calendar_view_week, label: l10n.topTwelveHour, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topDay, icon: Icons.today, label: l10n.topDay, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topWeek, icon: Icons.view_week_sharp, label: l10n.topWeek, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topMonth, icon: Icons.calendar_month, label: l10n.topMonth, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topThreeMonths, icon: Icons.calendar_month_outlined, label: l10n.topThreeMonths, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topSixMonths, icon: Icons.calendar_today_outlined, label: l10n.topSixMonths, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topNineMonths, icon: Icons.calendar_view_day_outlined, label: l10n.topNineMonths, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topYear, icon: Icons.calendar_today, label: l10n.topYear, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.topAll, icon: Icons.military_tech, label: l10n.topAll, @@ -252,22 +252,22 @@ List> getTopSearchSortTypeItems({Account? account } /// Returns the default (non-Top) sort type items for search -List> getDefaultSearchSortTypeItems({Account? account}) { +List> getDefaultSearchSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; - List> defaultSearchSortTypeItems = [ - ListPickerItem( + List> defaultSearchSortTypeItems = [ + ThunderListPickerItem( payload: SearchSortType.new_, icon: Icons.auto_awesome_rounded, label: l10n.new_, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.old, icon: Icons.access_time_outlined, label: l10n.old, ), - ListPickerItem( + ThunderListPickerItem( payload: SearchSortType.controversial, icon: Icons.warning_rounded, label: l10n.controversial, @@ -281,7 +281,7 @@ List> getDefaultSearchSortTypeItems({Account? acc } /// All search sort type items (default + top) combined. -List> allSearchSortTypeItems = [...getDefaultSearchSortTypeItems(), ...getTopSearchSortTypeItems()]; +List> allSearchSortTypeItems = [...getDefaultSearchSortTypeItems(), ...getTopSearchSortTypeItems()]; // ============================================================================ // Unified Sort Picker Widget @@ -312,7 +312,7 @@ List> allSearchSortTypeItems = [...getDefaultSear /// previouslySelected: SearchSortType.topYear, /// ) /// ``` -class SortPicker extends BottomSheetListPicker { +class SortPicker extends ThunderBottomSheetListPicker { /// The account that triggered the sort picker. Used to filter sort options by platform. final Account? account; @@ -326,13 +326,13 @@ class SortPicker extends BottomSheetListPicker { }) : super(items: _getItems(account)); /// Get the appropriate items based on the generic type T. - static List> _getItems(Account? account) { + static List> _getItems(Account? account) { if (T == PostSortType) { - return getDefaultPostSortTypeItems(account: account) as List>; + return getDefaultPostSortTypeItems(account: account) as List>; } else if (T == CommentSortType) { - return getCommentSortTypeItems(account: account) as List>; + return getCommentSortTypeItems(account: account) as List>; } else if (T == SearchSortType) { - return getDefaultSearchSortTypeItems(account: account) as List>; + return getDefaultSearchSortTypeItems(account: account) as List>; } throw ArgumentError('Unsupported sort type: $T. Must be PostSortType, CommentSortType, or SearchSortType.'); } @@ -380,7 +380,7 @@ class _SortPickerState extends State> { children: [ ..._generateList(_getDefaultItems(), theme), if (hasTopSubmenu) - PickerItem( + ThunderPickerItem( label: l10n.top, icon: Icons.military_tech, onSelected: () => setState(() => topSelected = true), @@ -445,23 +445,23 @@ class _SortPickerState extends State> { } /// Get the default (non-Top) items for the current sort type. - List> _getDefaultItems() { + List> _getDefaultItems() { if (T == PostSortType) { - return getDefaultPostSortTypeItems(account: widget.account) as List>; + return getDefaultPostSortTypeItems(account: widget.account) as List>; } else if (T == CommentSortType) { - return getCommentSortTypeItems(account: widget.account) as List>; + return getCommentSortTypeItems(account: widget.account) as List>; } else if (T == SearchSortType) { - return getDefaultSearchSortTypeItems(account: widget.account) as List>; + return getDefaultSearchSortTypeItems(account: widget.account) as List>; } return []; } /// Get the "Top" items for the current sort type. - List> _getTopItems() { + List> _getTopItems() { if (T == PostSortType) { - return getTopPostSortTypeItems(account: widget.account) as List>; + return getTopPostSortTypeItems(account: widget.account) as List>; } else if (T == SearchSortType) { - return getTopSearchSortTypeItems(account: widget.account) as List>; + return getTopSearchSortTypeItems(account: widget.account) as List>; } return []; } @@ -472,9 +472,9 @@ class _SortPickerState extends State> { return topItems.map((item) => item.payload).contains(widget.previouslySelected); } - List _generateList(List> items, ThemeData theme) { + List _generateList(List> items, ThemeData theme) { return items - .map((item) => PickerItem( + .map((item) => ThunderPickerItem( label: item.label, icon: item.icon, onSelected: () { diff --git a/lib/src/shared/text/selectable_text_modal.dart b/lib/src/shared/text/selectable_text_modal.dart index 8acfb6d6d..0da4c6c77 100644 --- a/lib/src/shared/text/selectable_text_modal.dart +++ b/lib/src/shared/text/selectable_text_modal.dart @@ -9,9 +9,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip; +import 'package:thunder/packages/ui/ui.dart'; void showSelectableTextModal(BuildContext context, {String? title, required String text}) { final l10n = GlobalContext.l10n; @@ -132,7 +131,7 @@ void showSelectableTextModal(BuildContext context, {String? title, required Stri Align( alignment: Alignment.centerLeft, child: viewSource - ? ScalableText( + ? ThunderScalableText( text, style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), textScaleFactor: contentFontSizeScale.textScaleFactor, diff --git a/lib/src/shared/webview/webview.dart b/lib/src/shared/webview/webview.dart index 59ae64331..8a3b16185 100644 --- a/lib/src/shared/webview/webview.dart +++ b/lib/src/shared/webview/webview.dart @@ -15,7 +15,7 @@ import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem; +import 'package:thunder/packages/ui/ui.dart'; class WebView extends StatefulWidget { final String url; diff --git a/test/helpers/ui_test_harness.dart b/test/helpers/ui_test_harness.dart new file mode 100644 index 000000000..29a34a6b3 --- /dev/null +++ b/test/helpers/ui_test_harness.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; + +Future pumpUiWidget(WidgetTester tester, Widget child) async { + GlobalContext.scaffoldMessengerKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + scaffoldMessengerKey: GlobalContext.scaffoldMessengerKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(extensions: const [ThunderTheme()]), + home: Scaffold(body: child), + ), + ); + await tester.pump(); +} diff --git a/test/packages/ui/thunder_action_chip_test.dart b/test/packages/ui/thunder_action_chip_test.dart new file mode 100644 index 000000000..9523b9afa --- /dev/null +++ b/test/packages/ui/thunder_action_chip_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderActionChip labelWidget overrides default label', (tester) async { + await pumpUiWidget( + tester, + const ThunderActionChip( + label: 'Ignored', + labelWidget: Text('Custom chip'), + ), + ); + + expect(find.text('Custom chip'), findsOneWidget); + expect(find.text('Ignored'), findsNothing); + }); +} diff --git a/test/packages/ui/thunder_actions_test.dart b/test/packages/ui/thunder_actions_test.dart new file mode 100644 index 000000000..9452c73de --- /dev/null +++ b/test/packages/ui/thunder_actions_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderBottomSheetAction renders slots and handles tap and long press', (tester) async { + var tapped = false; + var longPressed = false; + + await pumpUiWidget( + tester, + ThunderBottomSheetAction( + leading: const Icon(Icons.info), + trailing: const Icon(Icons.chevron_right), + title: 'Details', + subtitle: 'More information', + onTap: () => tapped = true, + onLongPress: () => longPressed = true, + ), + ); + + expect(find.text('Details'), findsOneWidget); + expect(find.text('More information'), findsOneWidget); + expect(find.byIcon(Icons.info), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + + await tester.tap(find.text('Details')); + await tester.pump(); + await tester.longPress(find.text('Details')); + await tester.pump(); + + expect(tapped, isTrue); + expect(longPressed, isTrue); + }); + + testWidgets('ThunderPopupMenuItem returns a value item with leading and trailing widgets', (tester) async { + var tapped = false; + final item = ThunderPopupMenuItem( + value: 'copy', + onTap: () => tapped = true, + icon: Icons.copy, + title: 'Copy', + trailing: const Icon(Icons.check), + ); + + expect(item.value, 'copy'); + + await pumpUiWidget(tester, item.child!); + + expect(find.text('Copy'), findsOneWidget); + expect(find.byIcon(Icons.copy), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + + item.onTap?.call(); + expect(tapped, isTrue); + }); + + testWidgets('ThunderPreviewActionRow toggles source label and copy action stays tappable', (tester) async { + var toggled = false; + + await pumpUiWidget( + tester, + ThunderPreviewActionRow( + text: 'raw text', + viewSource: false, + onViewSourceToggled: () => toggled = true, + viewSourceLabel: 'View source', + viewOriginalLabel: 'View rendered', + copyLabel: 'Copy', + copiedMessage: 'Copied', + ), + ); + + expect(find.text('View source'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + + await tester.tap(find.text('View source')); + await tester.pump(); + + expect(toggled, isTrue); + + await tester.tap(find.text('Copy')); + await tester.pump(); + }); + + testWidgets('ThunderActionChip renders default icon label and ignores taps when disabled', (tester) async { + var pressed = false; + + await pumpUiWidget( + tester, + Row( + children: [ + ThunderActionChip( + label: 'Enabled', + icon: Icons.add, + trailingIcon: Icons.check, + onPressed: () => pressed = true, + ), + const ThunderActionChip(label: 'Disabled'), + ], + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.tap(find.text('Enabled')); + await tester.pump(); + await tester.tap(find.text('Disabled')); + await tester.pump(); + + expect(pressed, isTrue); + }); +} diff --git a/test/packages/ui/thunder_avatar_identity_test.dart b/test/packages/ui/thunder_avatar_identity_test.dart new file mode 100644 index 000000000..7d9ab0c07 --- /dev/null +++ b/test/packages/ui/thunder_avatar_identity_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + test('ThunderAvatarData preserves configuration', () { + const data = ThunderAvatarData( + imageUrl: 'https://example.com/avatar.png', + radius: 24, + fallbackLabel: 'alice', + semanticLabel: 'Alice avatar', + ); + + expect(data.imageUrl, 'https://example.com/avatar.png'); + expect(data.radius, 24); + expect(data.fallbackLabel, 'alice'); + expect(data.semanticLabel, 'Alice avatar'); + }); + + testWidgets('ThunderAvatar renders uppercase fallback when url is absent', (tester) async { + await pumpUiWidget( + tester, + const ThunderAvatar( + data: ThunderAvatarData(fallbackLabel: 'alice', semanticLabel: 'Alice avatar'), + ), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.text('A'), findsOneWidget); + expect(find.bySemanticsLabel('Alice avatar'), findsOneWidget); + }); + + testWidgets('ThunderAvatar renders empty fallback for blank labels', (tester) async { + await pumpUiWidget( + tester, + const ThunderAvatar(data: ThunderAvatarData(fallbackLabel: '')), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.text(''), findsOneWidget); + }); + + testWidgets('ThunderScalableText applies scale, overflow, maxLines, and semantics', (tester) async { + await pumpUiWidget( + tester, + const ThunderScalableText( + 'Scaled text', + textScaleFactor: 1.5, + semanticsLabel: 'scaled semantics', + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + + final text = tester.widget(find.text('Scaled text')); + expect(text.maxLines, 1); + expect(text.overflow, TextOverflow.ellipsis); + expect(text.textScaler, TextScaler.noScaling); + expect(find.bySemanticsLabel('scaled semantics'), findsOneWidget); + }); + + testWidgets('ThunderIconLabel returns only icon when label is null or empty', (tester) async { + await pumpUiWidget( + tester, + const Column( + children: [ + ThunderIconLabel(icon: Icon(Icons.star), label: ''), + ThunderIconLabel(icon: Icon(Icons.favorite)), + ], + ), + ); + + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byType(Row), findsNothing); + }); + + testWidgets('ThunderIconLabel renders scaled label with custom gap', (tester) async { + await pumpUiWidget( + tester, + const ThunderIconLabel( + icon: Icon(Icons.shield), + label: 'Moderator', + gap: 12, + semanticsLabel: 'moderator label', + ), + ); + + expect(find.byType(Row), findsOneWidget); + expect(find.text('Moderator'), findsOneWidget); + expect(find.bySemanticsLabel('moderator label'), findsOneWidget); + }); + + test('ThunderIcon constants use the Thunder icon font', () { + expect(ThunderIcon.shield.fontFamily, 'Thunder'); + expect(ThunderIcon.robot.codePoint, 0xf544); + }); +} diff --git a/test/packages/ui/thunder_bottom_navigation_bar_test.dart b/test/packages/ui/thunder_bottom_navigation_bar_test.dart new file mode 100644 index 000000000..1e3f028fa --- /dev/null +++ b/test/packages/ui/thunder_bottom_navigation_bar_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderBottomNavigationBar selects destination on tap', (tester) async { + var selectedIndex = 0; + + await pumpUiWidget( + tester, + ThunderBottomNavigationBar( + selectedIndex: selectedIndex, + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.search), label: 'Search'), + ], + onDestinationSelected: (index) => selectedIndex = index, + ), + ); + + await tester.tap(find.text('Search')); + await tester.pump(); + + expect(selectedIndex, 1); + }); + + testWidgets('ThunderBottomNavigationBar fires long press callback', (tester) async { + var longPressed = false; + + await pumpUiWidget( + tester, + ThunderBottomNavigationBar( + selectedIndex: 0, + longPressTimeout: const Duration(milliseconds: 50), + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.search), label: 'Search'), + ], + onDestinationSelected: (_) {}, + onDestinationLongPresses: { + 1: () => longPressed = true, + }, + ), + ); + + final searchCenter = tester.getCenter(find.text('Search')); + final gesture = await tester.startGesture(searchCenter); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.up(); + await tester.pump(); + + expect(longPressed, isTrue); + }); +} diff --git a/test/packages/ui/thunder_bottom_sheet_list_picker_test.dart b/test/packages/ui/thunder_bottom_sheet_list_picker_test.dart new file mode 100644 index 000000000..1b9415653 --- /dev/null +++ b/test/packages/ui/thunder_bottom_sheet_list_picker_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderBottomSheetListPicker shows header and items', (tester) async { + var selected = false; + + await pumpUiWidget( + tester, + ThunderBottomSheetListPicker( + title: 'Choose one', + closeOnSelect: false, + items: const [ + ThunderPickerOption(label: 'Alpha', payload: 'a'), + ThunderPickerOption(label: 'Beta', payload: 'b'), + ], + onSelect: (_) async => selected = true, + ), + ); + + expect(find.text('Choose one'), findsOneWidget); + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + + await tester.tap(find.text('Alpha')); + await tester.pump(); + + expect(selected, isTrue); + }); + + testWidgets('ThunderBottomSheetListPicker renders with previouslySelected', (tester) async { + await pumpUiWidget( + tester, + ThunderBottomSheetListPicker( + title: 'Choose one', + closeOnSelect: false, + previouslySelected: 'b', + items: const [ + ThunderPickerOption(label: 'Alpha', payload: 'a'), + ThunderPickerOption(label: 'Beta', payload: 'b'), + ], + ), + ); + + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('ThunderBottomSheetListPicker scrolls long item lists', (tester) async { + ThunderPickerOption? selected; + + await pumpUiWidget( + tester, + SizedBox( + height: 240, + child: ThunderBottomSheetListPicker( + title: 'Long list', + closeOnSelect: false, + items: [ + for (var i = 0; i < 40; i++) ThunderPickerOption(label: 'Item $i', payload: i), + ], + onSelect: (item) async => selected = item, + ), + ), + ); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 39'), findsNothing); + + await tester.scrollUntilVisible( + find.text('Item 39'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('Item 39')); + await tester.pump(); + + expect(selected?.payload, 39); + }); + + testWidgets('ThunderBottomSheetListPicker updates heading and checkbox rows without closing', (tester) async { + var checked = false; + + await pumpUiWidget( + tester, + ThunderBottomSheetListPicker( + title: 'Choose many', + closeOnSelect: false, + heading: const Text('Initial heading'), + onUpdateHeading: () => const Text('Updated heading'), + items: [ + ThunderPickerOption( + label: 'Checkbox', + payload: 'checkbox', + isChecked: () => checked, + ), + ], + onSelect: (_) async => checked = true, + ), + ); + + expect(find.text('Initial heading'), findsOneWidget); + expect(find.byIcon(Icons.check_box_outline_blank_rounded), findsOneWidget); + + await tester.tap(find.text('Checkbox')); + await tester.pump(); + + expect(checked, isTrue); + expect(find.text('Updated heading'), findsOneWidget); + expect(find.byIcon(Icons.check_box_rounded), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_common_primitives_test.dart b/test/packages/ui/thunder_common_primitives_test.dart new file mode 100644 index 000000000..84c610dbf --- /dev/null +++ b/test/packages/ui/thunder_common_primitives_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderEmptyText renders italic muted message with custom padding', (tester) async { + await pumpUiWidget( + tester, + const ThunderEmptyText( + message: 'Nothing here', + padding: EdgeInsets.all(7), + ), + ); + + final padding = tester.widget(find.byType(Padding).last); + final text = tester.widget(find.text('Nothing here')); + + expect(padding.padding, const EdgeInsets.all(7)); + expect(text.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('ThunderSkeletonBar and ThunderSkeletonPlaceholder render configured bars', (tester) async { + await pumpUiWidget( + tester, + const Column( + children: [ + ThunderSkeletonBar(width: 42, height: 8, opacity: 0.4), + ThunderSkeletonPlaceholder( + bars: [ + ThunderSkeletonBarSpec(width: 10), + ThunderSkeletonBarSpec(width: 20, height: 12), + ], + ), + ], + ), + ); + + expect(find.byType(ThunderSkeletonBar), findsNWidgets(3)); + }); + + testWidgets('ThunderSkeletonPlaceholder.post renders three default bars', (tester) async { + await pumpUiWidget(tester, const ThunderSkeletonPlaceholder.post()); + + expect(find.byType(ThunderSkeletonBar), findsNWidgets(3)); + }); + + testWidgets('ThunderStateActions renders primary, secondary, and loading states', (tester) async { + var firstPressed = false; + var secondPressed = false; + + await pumpUiWidget( + tester, + ThunderStateActions( + actions: [ + ThunderStateAction(label: 'Retry', onPressed: () => firstPressed = true), + ThunderStateAction(label: 'Details', onPressed: () => secondPressed = true), + ThunderStateAction(label: 'Loading', onPressed: () {}, loading: true), + ], + ), + ); + + expect(find.byType(ElevatedButton), findsOneWidget); + expect(find.byType(TextButton), findsNWidgets(2)); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.tap(find.text('Details')); + await tester.pump(); + + expect(firstPressed, isTrue); + expect(secondPressed, isTrue); + }); + + testWidgets('ThunderStateActions returns empty box for no actions', (tester) async { + await pumpUiWidget(tester, const ThunderStateActions(actions: [])); + + expect(find.byType(SizedBox), findsWidgets); + expect(find.byType(ElevatedButton), findsNothing); + }); + + testWidgets('ThunderStateIcon uses compact and custom color configuration', (tester) async { + await pumpUiWidget( + tester, + const Row( + children: [ + ThunderStateIcon(icon: Icons.error, color: Colors.green), + ThunderStateIcon(icon: Icons.warning, compact: true), + ], + ), + ); + + final icons = tester.widgetList(find.byType(Icon)).toList(); + expect(icons.first.color, Colors.green); + expect(icons.first.size, 100); + expect(icons.last.size, 40); + }); + + testWidgets('ThunderStateText supports title-only, message-only, and italic message', (tester) async { + await pumpUiWidget( + tester, + const Column( + children: [ + ThunderStateText(title: 'Title only'), + ThunderStateText(message: 'Message only', italic: true), + ], + ), + ); + + expect(find.text('Title only'), findsOneWidget); + final message = tester.widget(find.text('Message only')); + expect(message.style?.fontStyle, FontStyle.italic); + }); +} diff --git a/test/packages/ui/thunder_composer_bar_test.dart b/test/packages/ui/thunder_composer_bar_test.dart new file mode 100644 index 000000000..bfebd9bd1 --- /dev/null +++ b/test/packages/ui/thunder_composer_bar_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderComposerBar renders slot children', (tester) async { + await pumpUiWidget( + tester, + ThunderComposerBar( + leading: const Icon(Icons.add), + textField: const TextField(decoration: InputDecoration(hintText: 'Message')), + trailing: IconButton(onPressed: () {}, icon: const Icon(Icons.send)), + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.send), findsOneWidget); + expect(find.text('Message'), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_dialog_test.dart b/test/packages/ui/thunder_dialog_test.dart new file mode 100644 index 000000000..eb8ec1721 --- /dev/null +++ b/test/packages/ui/thunder_dialog_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderDialog renders title and content', (tester) async { + await pumpUiWidget( + tester, + const ThunderDialog( + title: 'Confirm', + contentText: 'Are you sure?', + primaryButtonText: 'OK', + secondaryButtonText: 'Cancel', + ), + ); + + expect(find.text('Confirm'), findsOneWidget); + expect(find.text('Are you sure?'), findsOneWidget); + expect(find.text('OK'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('ThunderDialog primary button respects enable callback', (tester) async { + await pumpUiWidget( + tester, + ThunderDialog( + title: 'Edit', + contentWidgetBuilder: (setEnabled) => TextButton( + onPressed: () => setEnabled(false), + child: const Text('Disable'), + ), + primaryButtonText: 'Save', + onPrimaryButtonPressed: (_, __) {}, + ), + ); + + final enabledSave = tester.widget(find.widgetWithText(FilledButton, 'Save')); + expect(enabledSave.onPressed, isNotNull); + + await tester.tap(find.text('Disable')); + await tester.pump(); + + final disabledSave = tester.widget(find.widgetWithText(FilledButton, 'Save')); + expect(disabledSave.onPressed, isNull); + }); + + testWidgets('showThunderDialog opens dialog portal', (tester) async { + await pumpUiWidget(tester, const SizedBox.shrink()); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [ThunderTheme()]), + home: Builder( + builder: (context) { + return TextButton( + onPressed: () => showThunderDialog( + context: context, + title: 'Portal', + contentText: 'From portal', + primaryButtonText: 'Done', + ), + child: const Text('Open'), + ); + }, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Portal'), findsOneWidget); + expect(find.text('From portal'), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_expandable_fab_test.dart b/test/packages/ui/thunder_expandable_fab_test.dart new file mode 100644 index 000000000..78eee2afd --- /dev/null +++ b/test/packages/ui/thunder_expandable_fab_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderExpandableFab controlled open syncs with parent', (tester) async { + var isOpen = false; + + await pumpUiWidget( + tester, + StatefulBuilder( + builder: (context, setState) { + return SizedBox( + height: 300, + width: 300, + child: Column( + children: [ + Switch( + value: isOpen, + onChanged: (value) => setState(() => isOpen = value), + ), + Expanded( + child: ThunderExpandableFab( + open: isOpen, + distance: 60, + icon: const Icon(Icons.add), + children: const [ + ThunderFabActionButton(icon: Icon(Icons.edit), label: 'Edit'), + ], + ), + ), + ], + ), + ); + }, + ), + ); + + expect(find.text('Edit'), findsNothing); + + await tester.tap(find.byType(Switch)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Edit'), findsOneWidget); + }); + + testWidgets('ThunderExpandableFab opens on vertical drag up', (tester) async { + await pumpUiWidget( + tester, + const SizedBox( + height: 300, + width: 300, + child: ThunderExpandableFab( + distance: 60, + icon: Icon(Icons.add), + children: [ + ThunderFabActionButton(icon: Icon(Icons.edit), label: 'Edit'), + ], + ), + ), + ); + + expect(find.text('Edit'), findsNothing); + + await tester.drag(find.byType(FloatingActionButton), const Offset(0, -80)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Edit'), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_layout_test.dart b/test/packages/ui/thunder_layout_test.dart new file mode 100644 index 000000000..c415ced84 --- /dev/null +++ b/test/packages/ui/thunder_layout_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderBottomSheetHeader renders title, subtitle, and slots', (tester) async { + await pumpUiWidget( + tester, + const ThunderBottomSheetHeader( + title: 'Header', + subtitle: 'Subtitle', + leading: Icon(Icons.arrow_back), + trailing: Icon(Icons.close), + ), + ); + + expect(find.text('Header'), findsOneWidget); + expect(find.text('Subtitle'), findsOneWidget); + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('ThunderBottomSheetNavigator navigates forward, back, and close', (tester) async { + var closed = false; + + await pumpUiWidget( + tester, + ThunderBottomSheetNavigator( + initialPage: 1, + titleBuilder: (_, page) => 'Page $page', + onClose: () => closed = true, + pageBuilder: (_, page, goTo, goBack) { + return Column( + children: [ + Text('Body $page'), + TextButton(onPressed: () => goTo(page + 1), child: const Text('Next')), + TextButton(onPressed: goBack, child: const Text('Back from body')), + ], + ); + }, + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close_rounded)); + await tester.pumpAndSettle(); + expect(closed, isTrue); + }); + + testWidgets('ThunderConditionalParent applies true, false, and null builders', (tester) async { + await pumpUiWidget( + tester, + Column( + children: [ + ThunderConditionalParent( + condition: true, + parentBuilder: (child) => DecoratedBox(decoration: const BoxDecoration(color: Colors.red), child: child), + child: const Text('True child'), + ), + ThunderConditionalParent( + condition: false, + parentBuilder: (child) => DecoratedBox(decoration: const BoxDecoration(color: Colors.red), child: child), + parentBuilderElse: (child) => Padding(padding: const EdgeInsets.all(4), child: child), + child: const Text('False child'), + ), + ThunderConditionalParent( + condition: true, + parentBuilder: (child) => child, + child: const Text('Plain child'), + ), + ], + ), + ); + + expect(find.byType(DecoratedBox), findsOneWidget); + expect(find.byType(Padding), findsWidgets); + expect(find.text('Plain child'), findsOneWidget); + }); + + testWidgets('ThunderDivider renders box and sliver variants', (tester) async { + await pumpUiWidget( + tester, + const Column( + children: [ + ThunderDivider(sliver: false, padding: false, thickness: 3), + Expanded( + child: CustomScrollView( + slivers: [ + ThunderDivider(sliver: true), + ], + ), + ), + ], + ), + ); + + expect(find.byType(Divider), findsNWidgets(2)); + expect(find.byType(SliverToBoxAdapter), findsOneWidget); + }); + + testWidgets('ThunderMarquee renders child in requested scroll direction', (tester) async { + await pumpUiWidget( + tester, + const SizedBox( + height: 40, + child: ThunderMarquee( + autoRepeat: false, + direction: Axis.vertical, + pauseDuration: Duration.zero, + animationDuration: Duration(milliseconds: 1), + backDuration: Duration(milliseconds: 1), + child: Text('Scrolling child'), + ), + ), + ); + + expect(find.text('Scrolling child'), findsOneWidget); + final scroll = tester.widget(find.byType(SingleChildScrollView)); + expect(scroll.scrollDirection, Axis.vertical); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + }); + + testWidgets('ThunderMetadataRow animates optional secondary segment', (tester) async { + await pumpUiWidget(tester, const ThunderMetadataRow(primary: 'Primary', secondary: 'Secondary')); + + expect(find.text('Primary'), findsOneWidget); + expect(find.text('Secondary'), findsOneWidget); + expect(find.text('•'), findsOneWidget); + }); + + testWidgets('ThunderSectionTitle, divider, and sidebar stat render configured text', (tester) async { + await pumpUiWidget( + tester, + const Column( + children: [ + ThunderSectionTitle(title: 'Section', description: 'Description'), + Row(children: [ThunderSectionDivider(indent: 7)]), + ThunderSidebarStat(icon: Icons.people, label: '12 users'), + ], + ), + ); + + expect(find.text('Section'), findsOneWidget); + expect(find.text('Description'), findsOneWidget); + expect(find.text('12 users'), findsOneWidget); + expect(find.byIcon(Icons.people), findsOneWidget); + }); + + testWidgets('ThunderSelectableTileShell reflects selection and tap behavior', (tester) async { + var tapped = false; + + await pumpUiWidget( + tester, + ThunderSelectableTileShell( + selected: true, + reordering: true, + onTap: () => tapped = true, + child: const Text('Selectable'), + ), + ); + + await tester.tap(find.text('Selectable')); + await tester.pump(); + + expect(tapped, isTrue); + expect(find.text('Selectable'), findsOneWidget); + }); + + testWidgets('ThunderSliverAdapter wraps child only in sliver mode', (tester) async { + await pumpUiWidget( + tester, + const Column( + children: [ + ThunderSliverAdapter(sliver: false, child: Text('Box child')), + Expanded( + child: CustomScrollView( + slivers: [ + ThunderSliverAdapter(sliver: true, fillRemaining: true, child: Text('Sliver child')), + ], + ), + ), + ], + ), + ); + + expect(find.text('Box child'), findsOneWidget); + expect(find.text('Sliver child'), findsOneWidget); + expect(find.byType(SliverFillRemaining), findsOneWidget); + }); + + testWidgets('ThunderTopBarScrim hides when not visible and positions when visible', (tester) async { + await pumpUiWidget( + tester, + const Stack( + children: [ + ThunderTopBarScrim(visible: false), + ThunderTopBarScrim(visible: true, color: Colors.purple), + ], + ), + ); + + expect(find.byType(Positioned), findsOneWidget); + expect(find.byType(SizedBox), findsWidgets); + }); +} diff --git a/test/packages/ui/thunder_media_preview_error_test.dart b/test/packages/ui/thunder_media_preview_error_test.dart new file mode 100644 index 000000000..de315605f --- /dev/null +++ b/test/packages/ui/thunder_media_preview_error_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderMediaPreviewError shows icon', (tester) async { + await pumpUiWidget( + tester, + const SizedBox( + height: 80, + width: 80, + child: ThunderMediaPreviewError(icon: Icons.image_not_supported_outlined), + ), + ); + + expect(find.byIcon(Icons.image_not_supported_outlined), findsOneWidget); + }); + + testWidgets('ThunderMediaPreviewError shows retry when enabled', (tester) async { + var retried = false; + + await pumpUiWidget( + tester, + SizedBox( + height: 80, + width: 80, + child: ThunderMediaPreviewError( + icon: Icons.image_not_supported_outlined, + canRetry: true, + onRetry: () => retried = true, + ), + ), + ); + + expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); + + await tester.tap(find.byIcon(Icons.refresh_rounded)); + await tester.pump(); + + expect(retried, isTrue); + }); +} diff --git a/test/packages/ui/thunder_media_test.dart b/test/packages/ui/thunder_media_test.dart new file mode 100644 index 000000000..085856bea --- /dev/null +++ b/test/packages/ui/thunder_media_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +final Uint8List _onePixelPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', +); + +void main() { + test('ThunderImageViewerSource factories preserve source data and content type', () { + final memory = ThunderImageViewerSource.memory(_onePixelPng, contentType: 'image/png'); + const network = ThunderImageViewerSource.network('https://example.com/image.png', contentType: 'image/png'); + + expect(memory, isA()); + expect((memory as ThunderImageViewerMemorySource).bytes, _onePixelPng); + expect(memory.contentType, 'image/png'); + expect(network, isA()); + expect((network as ThunderImageViewerNetworkSource).url, 'https://example.com/image.png'); + }); + + testWidgets('ThunderImageViewer renders memory source and handles tap, long press, and double tap scale', (tester) async { + var tapped = false; + var longPressed = false; + final scales = []; + + await pumpUiWidget( + tester, + SizedBox( + height: 300, + width: 300, + child: ThunderImageViewer( + source: ThunderImageViewerSource.memory(_onePixelPng), + semanticLabel: 'Memory image', + onTap: () => tapped = true, + onLongPress: () => longPressed = true, + onScaleChanged: scales.add, + ), + ), + ); + + expect(find.byType(Image), findsOneWidget); + + await tester.tap(find.byType(ThunderImageViewer)); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tap(find.byType(ThunderImageViewer)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ThunderImageViewer)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.longPress(find.byType(ThunderImageViewer)); + await tester.pump(); + + expect(tapped, isTrue); + expect(longPressed, isTrue); + expect(scales, isNotEmpty); + }); + + testWidgets('ThunderImageViewer renders custom network loading builder', (tester) async { + await pumpUiWidget( + tester, + SizedBox( + height: 200, + width: 200, + child: ThunderImageViewer( + source: const ThunderImageViewerSource.network('https://example.invalid/image.png'), + loadingBuilder: (_) => const Text('Loading image'), + ), + ), + ); + + expect(find.text('Loading image'), findsOneWidget); + }); + + testWidgets('ThunderMediaPreviewError hides on blur and retries when enabled', (tester) async { + var retried = false; + + await pumpUiWidget( + tester, + Column( + children: [ + const ThunderMediaPreviewError(icon: Icons.broken_image, blur: true), + ThunderMediaPreviewError( + icon: Icons.broken_image, + viewed: true, + canRetry: true, + retryTooltip: 'Try again', + onRetry: () => retried = true, + ), + ], + ), + ); + + expect(find.byIcon(Icons.broken_image), findsNothing); + expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); + + await tester.tap(find.byIcon(Icons.refresh_rounded)); + await tester.pump(); + + expect(retried, isTrue); + }); +} diff --git a/test/packages/ui/thunder_multi_action_dismissible_test.dart b/test/packages/ui/thunder_multi_action_dismissible_test.dart new file mode 100644 index 000000000..d09d70e25 --- /dev/null +++ b/test/packages/ui/thunder_multi_action_dismissible_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderMultiActionDismissible keeps dismissible key across rebuilds', (tester) async { + await pumpUiWidget( + tester, + StatefulBuilder( + builder: (context, setState) => ThunderMultiActionDismissible( + direction: DismissDirection.startToEnd, + leftActions: [ + ThunderSwipeAction( + value: null, + color: (context) => Colors.red, + ), + ], + rightActions: const [], + child: TextButton( + onPressed: () => setState(() {}), + child: const Text('child'), + ), + ), + ), + ); + + final dismissibleFinder = find.byType(Dismissible); + expect(dismissibleFinder, findsOneWidget); + + final keyBeforeRebuild = tester.widget(dismissibleFinder).key; + + await tester.tap(find.text('child')); + await tester.pump(); + + final keyAfterRebuild = tester.widget(dismissibleFinder).key; + expect(keyBeforeRebuild, keyAfterRebuild); + }); +} diff --git a/test/packages/ui/thunder_pickers_settings_test.dart b/test/packages/ui/thunder_pickers_settings_test.dart new file mode 100644 index 000000000..ce7b15695 --- /dev/null +++ b/test/packages/ui/thunder_pickers_settings_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderPickerItem supports slots, selection, disabled, and tap', (tester) async { + var selected = false; + + await pumpUiWidget( + tester, + Column( + children: [ + ThunderPickerItem( + label: 'Alpha', + subtitle: 'First', + icon: Icons.filter_1, + trailingIcon: Icons.check, + isSelected: true, + onSelected: () => selected = true, + ), + const ThunderPickerItem( + label: 'Ignored', + labelWidget: Text('Custom label'), + subtitleWidget: Text('Custom subtitle'), + leading: Icon(Icons.star), + ), + ], + ), + ); + + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('First'), findsOneWidget); + expect(find.byIcon(Icons.filter_1), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + expect(find.text('Custom label'), findsOneWidget); + expect(find.text('Custom subtitle'), findsOneWidget); + + await tester.tap(find.text('Alpha')); + await tester.pump(); + expect(selected, isTrue); + }); + + testWidgets('ThunderMultiPickerItem renders enabled and disabled buttons with tooltips', (tester) async { + var picked = false; + + await pumpUiWidget( + tester, + ThunderMultiPickerItem( + pickerItems: [ + ThunderMultiPickerItemData(label: 'Pick', icon: Icons.add, onSelected: () => picked = true, foregroundColor: Colors.green), + const ThunderMultiPickerItemData(label: 'Disabled', icon: Icons.remove, onSelected: null), + ], + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.remove), findsOneWidget); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + expect(picked, isTrue); + }); + + testWidgets('showThunderListPicker opens default picker and custom picker override', (tester) async { + await pumpUiWidget( + tester, + Builder( + builder: (context) => Column( + children: [ + TextButton( + onPressed: () => showThunderListPicker( + context: context, + title: 'Pick one', + items: const [ + ThunderListPickerItem(label: 'One', payload: 'one'), + ThunderListPickerItem(label: 'Two', payload: 'two'), + ], + selected: const ThunderListPickerItem(label: 'One', payload: 'one'), + ), + child: const Text('Open default'), + ), + TextButton( + onPressed: () => showThunderListPicker( + context: context, + title: 'Ignored', + items: const [], + selected: const ThunderListPickerItem(label: 'Empty', payload: 'empty'), + customPicker: const Text('Custom picker'), + ), + child: const Text('Open custom'), + ), + ], + ), + ), + ); + + await tester.tap(find.text('Open default')); + await tester.pumpAndSettle(); + expect(find.text('Pick one'), findsOneWidget); + expect(find.text('Two'), findsOneWidget); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open custom')); + await tester.pumpAndSettle(); + expect(find.text('Custom picker'), findsOneWidget); + }); + + testWidgets('ThunderListOption displays formatted value and opens custom picker', (tester) async { + await pumpUiWidget( + tester, + ThunderListOption( + title: 'Sort', + subtitle: 'Choose sort', + value: const ThunderListPickerItem(label: 'newComments', payload: 'new'), + options: const [ + ThunderListPickerItem(label: 'hot', payload: 'hot'), + ThunderListPickerItem(label: 'newComments', payload: 'new'), + ], + customListPicker: const Text('Custom list picker'), + ), + ); + + expect(find.text('Sort'), findsOneWidget); + expect(find.text('Choose sort'), findsOneWidget); + expect(find.textContaining('Comments'), findsOneWidget); + + await tester.tap(find.text('Sort')); + await tester.pumpAndSettle(); + expect(find.text('Custom list picker'), findsOneWidget); + }); + + testWidgets('ThunderListOption disabled does not open picker', (tester) async { + await pumpUiWidget( + tester, + const ThunderListOption( + title: 'Disabled sort', + disabled: true, + value: ThunderListPickerItem(label: 'hot', payload: 'hot'), + options: [ThunderListPickerItem(label: 'hot', payload: 'hot')], + ), + ); + + await tester.tap(find.text('Disabled sort')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + }); + + testWidgets('ThunderToggleOption toggles via tile and switch and respects disabled state', (tester) async { + final values = []; + + await pumpUiWidget( + tester, + Column( + children: [ + ThunderToggleOption( + title: 'Toggle', + value: false, + onChanged: values.add, + iconEnabled: Icons.toggle_on, + iconDisabled: Icons.toggle_off, + additionalTrailing: const [Text('Extra')], + ), + ThunderToggleOption( + title: 'Disabled toggle', + value: true, + disabled: true, + onChanged: values.add, + ), + const ThunderToggleOption(title: 'Placeholder'), + ], + ), + ); + + await tester.tap(find.text('Toggle')); + await tester.pump(); + await tester.tap(find.byType(Switch).first); + await tester.pump(); + await tester.tap(find.text('Disabled toggle')); + await tester.pump(); + + expect(values, [true, true]); + expect(find.text('Extra'), findsOneWidget); + expect(find.byType(ThunderSettingsSwitchTrailing), findsWidgets); + }); + + testWidgets('ThunderExpandableOption toggles child visibility', (tester) async { + await pumpUiWidget( + tester, + const ThunderExpandableOption( + title: 'Advanced', + leading: Icon(Icons.tune), + child: Text('Advanced body'), + ), + ); + + expect(find.text('Advanced body'), findsNothing); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(find.text('Advanced body'), findsOneWidget); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(find.text('Advanced body'), findsNothing); + }); +} diff --git a/test/packages/ui/thunder_section_header_test.dart b/test/packages/ui/thunder_section_header_test.dart new file mode 100644 index 000000000..d580c0b05 --- /dev/null +++ b/test/packages/ui/thunder_section_header_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderSectionHeader settings variant shows title and description', (tester) async { + await pumpUiWidget( + tester, + const ThunderSectionHeader( + title: 'General', + description: 'Account settings', + ), + ); + + expect(find.text('General'), findsOneWidget); + expect(find.text('Account settings'), findsOneWidget); + }); + + testWidgets('ThunderSectionHeader sidebar variant shows title row', (tester) async { + await pumpUiWidget( + tester, + const ThunderSectionHeader( + title: 'Stats', + variant: ThunderSectionHeaderVariant.sidebar, + ), + ); + + expect(find.text('Stats'), findsOneWidget); + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('ThunderSectionHeader sliver variant renders title and actions', (tester) async { + await pumpUiWidget( + tester, + CustomScrollView( + slivers: [ + ThunderSectionHeader( + title: 'Accounts', + variant: ThunderSectionHeaderVariant.sliver, + actions: [ + IconButton(onPressed: () {}, icon: const Icon(Icons.add)), + ], + ), + ], + ), + ); + + expect(find.text('Accounts'), findsOneWidget); + expect(find.byType(SliverToBoxAdapter), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_settings_tile_test.dart b/test/packages/ui/thunder_settings_tile_test.dart new file mode 100644 index 000000000..f1fe0e30e --- /dev/null +++ b/test/packages/ui/thunder_settings_tile_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderSettingsTile titleWidget overrides default title', (tester) async { + await pumpUiWidget( + tester, + const ThunderSettingsTile( + title: 'Ignored', + titleWidget: Text('Custom title'), + ), + ); + + expect(find.text('Custom title'), findsOneWidget); + expect(find.text('Ignored'), findsNothing); + }); +} diff --git a/test/packages/ui/thunder_settings_trailing_test.dart b/test/packages/ui/thunder_settings_trailing_test.dart new file mode 100644 index 000000000..9364eab53 --- /dev/null +++ b/test/packages/ui/thunder_settings_trailing_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +Size _widgetSize(WidgetTester tester, Finder finder) { + return tester.getSize(finder); +} + +void main() { + testWidgets('settings trailing widgets use their expected slot widths', (tester) async { + await pumpUiWidget( + tester, + Column( + children: [ + ThunderSettingsTile( + title: 'Navigate', + trailing: const ThunderSettingsChevronTrailing(), + ), + ThunderListOption( + title: 'Pick', + value: const ThunderListPickerItem(label: 'One', payload: 'one'), + options: const [ThunderListPickerItem(label: 'One', payload: 'one')], + ), + ThunderToggleOption( + title: 'Toggle', + value: true, + onChanged: (_) {}, + ), + const ThunderExpandableOption( + title: 'Expand', + child: Text('Body'), + ), + ], + ), + ); + + const thunderTheme = ThunderTheme(); + const chevronSlotWidth = 20.0; + final slotWidth = thunderTheme.settingsTileTrailingSlotWidth; + + expect(_widgetSize(tester, find.byType(ThunderSettingsChevronTrailing).first), Size(chevronSlotWidth, thunderTheme.settingsTileTrailingSlotHeight)); + expect(_widgetSize(tester, find.byType(ThunderSettingsChevronTrailing).last), Size(chevronSlotWidth, thunderTheme.settingsTileTrailingSlotHeight)); + expect(_widgetSize(tester, find.byType(ThunderSettingsSwitchTrailing)), Size(slotWidth, thunderTheme.settingsTileTrailingSlotHeight)); + expect(_widgetSize(tester, find.byType(ThunderSettingsExpandTrailing)), Size(slotWidth, thunderTheme.settingsTileTrailingSlotHeight)); + }); + + testWidgets('toggle and list options align leading icon gap through ThunderSettingsTile', (tester) async { + await pumpUiWidget( + tester, + Column( + children: [ + ThunderListOption( + title: 'List', + leading: const Icon(Icons.language_rounded, key: Key('list-icon')), + value: const ThunderListPickerItem(label: 'One', payload: 'one'), + options: const [ThunderListPickerItem(label: 'One', payload: 'one')], + ), + ThunderToggleOption( + title: 'Toggle', + value: true, + iconEnabled: Icons.toggle_on, + iconDisabled: Icons.toggle_off, + onChanged: (_) {}, + ), + ], + ), + ); + + const thunderTheme = ThunderTheme(); + final listIconBox = tester.getRect(find.byKey(const Key('list-icon'))); + final toggleIconBox = tester.getRect(find.byIcon(Icons.toggle_on)); + final listTitleBox = tester.getRect(find.text('List')); + final toggleTitleBox = tester.getRect(find.text('Toggle')); + + expect(listTitleBox.left - listIconBox.right, thunderTheme.settingsTileLeadingGap); + expect(toggleTitleBox.left - toggleIconBox.right, thunderTheme.settingsTileLeadingGap); + }); + + testWidgets('ThunderSettingsChevronTrailing applies disabled color', (tester) async { + await pumpUiWidget( + tester, + const ThunderSettingsChevronTrailing(disabled: true), + ); + + final icon = tester.widget(find.byIcon(Icons.chevron_right_rounded)); + final theme = Theme.of(tester.element(find.byType(ThunderSettingsChevronTrailing))); + const thunderTheme = ThunderTheme(); + + expect( + icon.color, + theme.colorScheme.onSurface.withValues(alpha: thunderTheme.settingsTileDisabledAlpha), + ); + }); +} diff --git a/test/packages/ui/thunder_snackbar_test.dart b/test/packages/ui/thunder_snackbar_test.dart new file mode 100644 index 000000000..f439d1d4d --- /dev/null +++ b/test/packages/ui/thunder_snackbar_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderSnackbar keeps dismissible key across rebuilds', (tester) async { + await pumpUiWidget( + tester, + StatefulBuilder( + builder: (context, setState) => ThunderSnackbar( + content: TextButton( + onPressed: () => setState(() {}), + child: const Text('snack content'), + ), + ), + ), + ); + + final dismissibleFinder = find.byType(Dismissible); + expect(dismissibleFinder, findsOneWidget); + + final keyBeforeRebuild = tester.widget(dismissibleFinder).key; + + await tester.tap(find.text('snack content')); + await tester.pump(); + + final keyAfterRebuild = tester.widget(dismissibleFinder).key; + expect(keyBeforeRebuild, keyAfterRebuild); + }); +} diff --git a/test/packages/ui/thunder_split_action_row_test.dart b/test/packages/ui/thunder_split_action_row_test.dart new file mode 100644 index 000000000..fa4dfd90c --- /dev/null +++ b/test/packages/ui/thunder_split_action_row_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderSplitActionRow renders leading and trailing', (tester) async { + await pumpUiWidget( + tester, + const ThunderSplitActionRow( + leading: Text('Leading'), + trailing: Text('Trailing'), + ), + ); + + expect(find.text('Leading'), findsOneWidget); + expect(find.text('Trailing'), findsOneWidget); + expect(find.byType(ThunderSplitActionRow), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_state_view_test.dart b/test/packages/ui/thunder_state_view_test.dart new file mode 100644 index 000000000..e13d7e160 --- /dev/null +++ b/test/packages/ui/thunder_state_view_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderStateView shows error title and retry action', (tester) async { + var retried = false; + + await pumpUiWidget( + tester, + ThunderStateView( + title: 'Load failed', + message: 'Try again', + actions: [ + ThunderStateAction( + label: 'Retry', + onPressed: () => retried = true, + primary: true, + ), + ], + ), + ); + + expect(find.text('Load failed'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + expect(find.byIcon(Icons.warning_rounded), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(retried, isTrue); + }); + + testWidgets('ThunderStateView.loading shows progress indicator', (tester) async { + await pumpUiWidget(tester, const ThunderStateView.loading()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('ThunderStateView empty mode renders italic text', (tester) async { + await pumpUiWidget( + tester, + const ThunderStateView( + mode: ThunderStateViewMode.empty, + title: 'Nothing here', + ), + ); + + expect(find.text('Nothing here'), findsOneWidget); + }); + + testWidgets('ThunderStateView custom mode renders child', (tester) async { + await pumpUiWidget( + tester, + const ThunderStateView( + mode: ThunderStateViewMode.custom, + child: Text('Custom body'), + ), + ); + + expect(find.text('Custom body'), findsOneWidget); + }); + + testWidgets('ThunderStateView sliver mode renders inside CustomScrollView', (tester) async { + await pumpUiWidget( + tester, + CustomScrollView( + slivers: [ + const ThunderStateView.loading(sliver: true), + ], + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(SliverFillRemaining), findsOneWidget); + }); +} diff --git a/test/packages/ui/thunder_swipe_action_background_test.dart b/test/packages/ui/thunder_swipe_action_background_test.dart new file mode 100644 index 000000000..175ee14b3 --- /dev/null +++ b/test/packages/ui/thunder_swipe_action_background_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +import '../../helpers/ui_test_harness.dart'; + +void main() { + testWidgets('ThunderSwipeActionBackground shows icon at given width', (tester) async { + await pumpUiWidget( + tester, + const SizedBox( + height: 80, + width: 200, + child: ThunderSwipeActionBackground( + alignment: Alignment.centerRight, + backgroundColor: Colors.red, + width: 100, + icon: Icons.delete, + ), + ), + ); + + expect(find.byIcon(Icons.delete), findsOneWidget); + expect(find.byType(AnimatedContainer), findsOneWidget); + }); + + testWidgets('ThunderSwipeActionBackground hides icon when null', (tester) async { + await pumpUiWidget( + tester, + const SizedBox( + height: 80, + width: 200, + child: ThunderSwipeActionBackground( + alignment: Alignment.centerLeft, + backgroundColor: Colors.blue, + width: 50, + ), + ), + ); + + expect(find.byType(Icon), findsNothing); + }); +} diff --git a/test/packages/ui/thunder_theme_test.dart b/test/packages/ui/thunder_theme_test.dart new file mode 100644 index 000000000..a37b90ffb --- /dev/null +++ b/test/packages/ui/thunder_theme_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; + +void main() { + test('ThunderTheme copyWith updates tokens', () { + const theme = ThunderTheme(); + final updated = theme.copyWith( + pickerSelectedAlpha: 0.4, + sectionDescriptionAlpha: 0.6, + viewerBackgroundColor: Colors.grey, + ); + + expect(updated.pickerSelectedAlpha, 0.4); + expect(updated.sectionDescriptionAlpha, 0.6); + expect(updated.viewerBackgroundColor, Colors.grey); + expect(updated.tileBorderRadius, theme.tileBorderRadius); + }); + + test('ThunderTheme lerp interpolates numeric and color tokens', () { + const start = ThunderTheme( + pickerSelectedAlpha: 0.0, + viewerBackgroundColor: Colors.black, + ); + const end = ThunderTheme( + pickerSelectedAlpha: 1.0, + viewerBackgroundColor: Colors.white, + ); + + final mid = start.lerp(end, 0.5); + + expect(mid.pickerSelectedAlpha, closeTo(0.5, 0.001)); + expect(mid.viewerBackgroundColor, isNot(equals(start.viewerBackgroundColor))); + expect(mid.viewerBackgroundColor, isNot(equals(end.viewerBackgroundColor))); + }); +} diff --git a/test/src/foundation/networking/api_client_factory_test.dart b/test/src/foundation/networking/api_client_factory_test.dart index cfb0395df..789e52ab3 100644 --- a/test/src/foundation/networking/api_client_factory_test.dart +++ b/test/src/foundation/networking/api_client_factory_test.dart @@ -102,6 +102,18 @@ void main() { expect(PlatformVersionCache().get('lemmy.test')?.toString(), '1.0.0'); }); + test('probeLemmySiteVersion uses http for local instance authorities', () async { + when(() => mockHttpClient.get(any())).thenAnswer( + (_) async => http.Response('{"version":"1.0.0-alpha.20"}', 200), + ); + + final version = await ApiClientFactory.probeLemmySiteVersion('127.0.0.1:8537', httpClient: mockHttpClient); + + expect(version?.toString(), '1.0.0-alpha.20'); + expect(PlatformVersionCache().get('127.0.0.1:8537')?.toString(), '1.0.0-alpha.20'); + verify(() => mockHttpClient.get(Uri.parse('http://127.0.0.1:8537/api/v4/site'))).called(1); + }); + test('resolved client defers resolution until first use and caches the client', () async { final api = MockThunderApiClient(); var callCount = 0; diff --git a/test/src/foundation/networking/base_api_client_test.dart b/test/src/foundation/networking/base_api_client_test.dart index 96b087bfd..4d2462574 100644 --- a/test/src/foundation/networking/base_api_client_test.dart +++ b/test/src/foundation/networking/base_api_client_test.dart @@ -93,6 +93,20 @@ void main() { }); group('BaseApiClient.request', () { + test('uses http for local instance authorities', () async { + const localAccount = Account( + id: 'local', + index: 0, + instance: '127.0.0.1:8536', + ); + final localClient = TestApiClient(account: localAccount, httpClient: mockHttpClient); + when(() => mockHttpClient.get(any(), headers: any(named: 'headers'))).thenAnswer((_) async => http.Response('{}', 200)); + + await localClient.request(HttpMethod.get, '/api/v3/site', {}); + + verify(() => mockHttpClient.get(Uri.parse('http://127.0.0.1:8536/api/v3/site'), headers: any(named: 'headers'))).called(1); + }); + test('wraps SocketException in NetworkException', () async { when(() => mockHttpClient.get(any(), headers: any(named: 'headers'))).thenThrow(const SocketException('offline')); diff --git a/test/src/foundation/networking/discovery/instance_discovery_service_test.dart b/test/src/foundation/networking/discovery/instance_discovery_service_test.dart index aac51ec94..d26a9830d 100644 --- a/test/src/foundation/networking/discovery/instance_discovery_service_test.dart +++ b/test/src/foundation/networking/discovery/instance_discovery_service_test.dart @@ -24,6 +24,12 @@ void main() { expect(normalizeInstanceHost('Lemmy.Test'), 'lemmy.test'); expect(normalizeInstanceHost('https://lemmy.test/path'), 'lemmy.test'); }); + + test('preserves explicit ports for local instances', () { + expect(normalizeInstanceHost('127.0.0.1:8536'), '127.0.0.1:8536'); + expect(normalizeInstanceHost('http://localhost:8030'), 'localhost:8030'); + expect(normalizeInstanceHost('10.0.2.2:8537'), '10.0.2.2:8537'); + }); }); group('discoverInstance', () { diff --git a/test/src/foundation/networking/instance_uri_test.dart b/test/src/foundation/networking/instance_uri_test.dart new file mode 100644 index 000000000..9d98ed159 --- /dev/null +++ b/test/src/foundation/networking/instance_uri_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/src/foundation/networking/instance_uri.dart'; + +void main() { + group('normalizeInstanceAuthority', () { + test('normalizes public hosts without ports', () { + expect(normalizeInstanceAuthority('Lemmy.Test'), 'lemmy.test'); + expect(normalizeInstanceAuthority('https://Lemmy.Test/path'), 'lemmy.test'); + }); + + test('preserves local ports', () { + expect(normalizeInstanceAuthority('127.0.0.1:8536'), '127.0.0.1:8536'); + expect(normalizeInstanceAuthority('http://localhost:8030'), 'localhost:8030'); + expect(normalizeInstanceAuthority('10.0.2.2:8537'), '10.0.2.2:8537'); + }); + }); + + group('buildInstanceUri', () { + test('uses https for public instances', () { + expect( + buildInstanceUri('lemmy.test', '/api/v3/site').toString(), + 'https://lemmy.test/api/v3/site', + ); + }); + + test('uses http for local instances with ports', () { + expect( + buildInstanceUri('127.0.0.1:8536', '/api/v3/site').toString(), + 'http://127.0.0.1:8536/api/v3/site', + ); + expect( + buildInstanceUri('10.0.2.2:8537', '/api/v4/site').toString(), + 'http://10.0.2.2:8537/api/v4/site', + ); + }); + + test('uses http for private LAN instance authorities', () { + expect( + buildInstanceUri('192.168.1.42:8536', '/api/v3/site').toString(), + 'http://192.168.1.42:8536/api/v3/site', + ); + expect( + buildInstanceUri('172.20.0.10:8537', '/api/v4/site').toString(), + 'http://172.20.0.10:8537/api/v4/site', + ); + }); + + test('keeps query parameters', () { + expect( + buildInstanceUri('localhost:8030', '/api/alpha/post/list', queryParameters: {'page': '1'}).toString(), + 'http://localhost:8030/api/alpha/post/list?page=1', + ); + }); + }); +} diff --git a/test/src/foundation/networking/upload_image_utils_test.dart b/test/src/foundation/networking/upload_image_utils_test.dart index a0b1a1276..9e221351e 100644 --- a/test/src/foundation/networking/upload_image_utils_test.dart +++ b/test/src/foundation/networking/upload_image_utils_test.dart @@ -51,7 +51,11 @@ void main() { test('builds pictrs url from files array', () { expect( - parseUploadImageUrl({'files': [{'file': 'abc.png'}]}, instance: 'lemmy.test', platformName: 'Lemmy'), + parseUploadImageUrl({ + 'files': [ + {'file': 'abc.png'} + ] + }, instance: 'lemmy.test', platformName: 'Lemmy'), 'https://lemmy.test/pictrs/image/abc.png', ); }); diff --git a/test/src/shared/fabs/gesture_fab_test.dart b/test/src/shared/fabs/gesture_fab_test.dart new file mode 100644 index 000000000..e41278f39 --- /dev/null +++ b/test/src/shared/fabs/gesture_fab_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunder/packages/ui/ui.dart'; +import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; +import 'package:thunder/src/shared/fabs/gesture_fab.dart'; + +void main() { + testWidgets('ActionButton closes feed FAB before invoking action', (tester) async { + final cubit = ShellChromeCubit()..setFeedFabOpen(true); + var pressed = false; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [ThunderTheme()]), + home: BlocProvider.value( + value: cubit, + child: Scaffold( + body: Center( + child: ActionButton( + title: 'Refresh', + icon: const Icon(Icons.refresh), + onPressed: () => pressed = true, + ), + ), + ), + ), + ), + ); + + expect(cubit.state.isFeedFabOpen, isTrue); + + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pump(); + + expect(pressed, isTrue); + expect(cubit.state.isFeedFabOpen, isFalse); + }); + + testWidgets('ActionButton closes post FAB when fabType is post', (tester) async { + final cubit = ShellChromeCubit()..setPostFabOpen(true); + var pressed = false; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [ThunderTheme()]), + home: BlocProvider.value( + value: cubit, + child: Scaffold( + body: Center( + child: ActionButton( + title: 'Reply', + icon: const Icon(Icons.reply), + fabType: FabType.post, + onPressed: () => pressed = true, + ), + ), + ), + ), + ), + ); + + expect(cubit.state.isPostFabOpen, isTrue); + + await tester.tap(find.byIcon(Icons.reply)); + await tester.pump(); + + expect(pressed, isTrue); + expect(cubit.state.isPostFabOpen, isFalse); + }); +}