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) => '').join('\n\n') ?? '';
- _bodyTextController.text = _bodyTextController.text.replaceRange(_bodyTextController.selection.end, _bodyTextController.selection.end, markdownImages);
+ final markdownImages = state.imageUrls?.map((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