prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,255 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class AnimateGradientShader extends StatefulWidget {
const AnimateGradientShader({
super.key,
required this.primaryColors,
required this.secondaryColors,
this.child,
this.primaryBegin = Alignment.topLeft,
this.primaryEnd = Alignment.topRight,
this.secondaryBegin = Alignment.bottomLeft,
this.secondaryEnd = Alignment.bottomRight,
this.primaryBeginGeometry,
this.primaryEndGeometry,
this.secondaryBeginGeometry,
this.secondaryEndGeometry,
this.textDirectionForGeometry = TextDirection.ltr,
this.controller,
this.duration = const Duration(seconds: 4),
this.animateAlignments = true,
this.reverse = true,
}) : assert(primaryColors.length >= 2),
assert(primaryColors.length == secondaryColors.length);
/// [controller]: pass this to have a fine control over the [Animation]
final AnimationController? controller;
/// [duration]: Time to switch between [Gradient].
/// By default its value is [Duration(seconds:4)]
final Duration duration;
/// [primaryColors]: These will be the starting colors of the [Animation].
final List<Color> primaryColors;
/// [secondaryColors]: These Colors are those in which the [primaryColors] will transition into.
final List<Color> secondaryColors;
/// [primaryBegin]: This is begin [Alignment] for [primaryColors].
/// By default its value is [Alignment.topLeft]
final Alignment primaryBegin;
/// [primaryBegin]: This is end [Alignment] for [primaryColors].
/// By default its value is [Alignment.topRight]
final Alignment primaryEnd;
/// [secondaryBegin]: This is begin [Alignment] for [secondaryColors].
/// By default its value is [Alignment.bottomLeft]
final Alignment secondaryBegin;
/// [secondaryEnd]: This is end [Alignment] for [secondaryColors].
/// By default its value is [Alignment.bottomRight]
final Alignment secondaryEnd;
/// Alternatively you can use [primaryBeginGeometry] over [primaryBegin] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [primaryBeginGeometry] will have higher priority than [primaryBegin]
final AlignmentGeometry? primaryBeginGeometry;
/// Alternatively you can use [primaryEndGeometry] over [primaryEnd] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [primaryEndGeometry] will have higher priority than [primaryEnd]
final AlignmentGeometry? primaryEndGeometry;
/// Alternatively you can use [secondaryBeginGeometry] over [secondaryBegin] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [secondaryBeginGeometry] will have higher priority than [secondaryBegin]
final AlignmentGeometry? secondaryBeginGeometry;
/// Alternatively you can use [secondaryEndGeometry] over [secondaryEnd] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [secondaryEndGeometry] will have higher priority than [secondaryEnd]
final AlignmentGeometry? secondaryEndGeometry;
/// This is the [TextDirection] which is gonna be used to resolve [AlignmentGeometry] passed through
/// [primaryBeginGeometry], [primaryEndGeometry], [secondaryBeginGeometry], [secondaryEndGeometry]
final TextDirection textDirectionForGeometry;
/// [animateAlignments]: set to false if you don't want to animate the alignments.
/// This can provide you way cooler animations
final bool animateAlignments;
/// [reverse]: set it to false if you don't want to reverse the animation.
/// using that it will go into one direction only
final bool reverse;
final Widget? child;
@override
State<AnimateGradientShader> createState() => _AnimateGradientShaderState();
}
class _AnimateGradientShaderState extends State<AnimateGradientShader>
with TickerProviderStateMixin {
AnimationController? _controller;
Animation<double>? _animation;
late List<ColorTween> _colorTween;
late AlignmentTween begin;
late AlignmentTween end;
List<Color> primaryColors = [];
List<Color> secondaryColors = [];
bool _disableAnimations = false;
@override
void initState() {
_initialize();
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final disableAnimations = MediaQuery.disableAnimationsOf(context);
if (disableAnimations != _disableAnimations) {
_disableAnimations = disableAnimations;
_setAnimations();
}
}
@override
void didUpdateWidget(AnimateGradientShader oldWidget) {
_initialize();
super.didUpdateWidget(oldWidget);
}
void _initialize() {
primaryColors = widget.primaryColors;
secondaryColors = widget.secondaryColors;
_colorTween = _getColorTweens();
if (widget.animateAlignments) _setAlignmentTweens();
_setAnimations();
}
@override
Widget build(BuildContext context) {
if (_animation == null) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: _animation!,
builder: (BuildContext context, Widget? child) {
final gradient = LinearGradient(
begin: widget.animateAlignments
? begin.evaluate(_animation!)
: widget.primaryBegin,
end: widget.animateAlignments
? end.evaluate(_animation!)
: widget.primaryEnd,
colors: _evaluateColors(_animation!),
);
return ShaderMask(
shaderCallback: (Rect bounds) {
return gradient.createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height),
);
},
child: widget.child,
);
},
);
}
List<ColorTween> _getColorTweens() {
if (widget.primaryColors.length != widget.secondaryColors.length) {
throw Exception('primaryColors.length != secondaryColors.length');
}
final List<ColorTween> colorTweens = [];
for (int i = 0; i < primaryColors.length; i++) {
colorTweens.add(
ColorTween(begin: primaryColors[i], end: secondaryColors[i]),
);
}
return colorTweens;
}
List<Color> _evaluateColors(Animation<double> animation) {
final List<Color> colors = [];
for (int i = 0; i < _colorTween.length; i++) {
colors.add(_colorTween[i].evaluate(animation)!);
}
return colors;
}
void _setAlignmentTweens() {
final primaryBeginGeometry = widget.primaryBeginGeometry?.resolve(
widget.textDirectionForGeometry,
);
final primaryEndGeometry = widget.primaryEndGeometry?.resolve(
widget.textDirectionForGeometry,
);
final secondaryBeginGeometry = widget.secondaryBeginGeometry?.resolve(
widget.textDirectionForGeometry,
);
final secondaryEndGeometry = widget.secondaryEndGeometry?.resolve(
widget.textDirectionForGeometry,
);
begin = AlignmentTween(
begin: primaryBeginGeometry ?? widget.primaryBegin,
end: primaryEndGeometry ?? widget.primaryEnd,
);
end = AlignmentTween(
begin: secondaryBeginGeometry ?? widget.secondaryBegin,
end: secondaryEndGeometry ?? widget.secondaryEnd,
);
}
void _setAnimations() {
_controller?.dispose();
_controller =
widget.controller ??
AnimationController(vsync: this, duration: widget.duration);
if (_disableAnimations) {
_controller!.value = 0;
} else {
// ignore: discarded_futures
_controller!.repeat(reverse: widget.reverse);
}
_animation = CurvedAnimation(parent: _controller!, curve: Curves.easeInOut);
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
}
@@ -0,0 +1,365 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// An internal representation of a child widget subtree that is a child of
/// the [AnimatedIndexedStack].
///
/// This keeps track of animation controllers, keys, and the child widget.
class _ChildEntry {
_ChildEntry({
required this.key,
required this.primaryController,
required this.secondaryController,
required this.child,
});
/// The key of this entry.
/// This is usually a [GlobalKey] to ensure that children do not lose their state.
final Key key;
/// The animation controller for the child's transition.
final AnimationController primaryController;
/// The (curved) animation being used to drive the transition.
final AnimationController secondaryController;
Widget child;
/// Release the resources used by this object.
///
/// The object is no longer usable after this method is called.
void dispose() {
primaryController.dispose();
secondaryController.dispose();
}
@override
String toString() => 'AnimatedIndexedStackEntry#${shortHash(this)}($child)';
}
enum _ChildAnimationDirection {
primaryForward,
primaryReverse,
secondaryForward,
secondaryReverse,
}
/// A Widget that shows a single child from a list of children.
/// Changing the index will animate the change of widgets according to the [transitionBuilder].
/// Removing the widget at the current index will also animate the change.
///
/// Widgets which are not currently visible will be kept alive until they are removed.
class AnimatedIndexedStack extends StatefulWidget {
const AnimatedIndexedStack({
super.key,
this.index = 0,
this.duration = const Duration(milliseconds: 300),
this.reverse = false,
required this.transitionBuilder,
this.layoutBuilder = defaultLayoutBuilder,
this.children = const [],
});
/// The index of the child to show.
///
/// If this is null, none of the children will be shown.
final int? index;
/// The duration of the transition from the old [child] value to the new one.
final Duration duration;
/// Indicates whether the new [child] will visually appear on top of or
/// underneath the old child.
final bool reverse;
/// A function that wraps a new [child] with a primary and secondary animation
/// set define how the child appears and disappears.
final Widget Function(
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
)
transitionBuilder;
/// A function that lays out all the children in this IndexedStack.
/// This defaults to [PageTransitionSwitcher.defaultLayoutBuilder].
final Widget Function(List<Widget> entries) layoutBuilder;
/// The child widgets of the stack.
/// Only the child at index [index] will be shown.
/// To correctly keep track of the state of child widgets, they must be given unique keys.
final List<Widget> children;
/// The default layout builder for [AnimatedIndexedStack].
/// Contains all the children in a [Stack].
static Widget defaultLayoutBuilder(List<Widget> entries) {
return Stack(alignment: Alignment.center, children: entries);
}
@override
State<AnimatedIndexedStack> createState() => _AnimatedIndexedStackState();
}
class _AnimatedIndexedStackState extends State<AnimatedIndexedStack>
with TickerProviderStateMixin {
/// All entries contained in this Stack.
/// This is built from the children list, but may also contain entries which are animating out.
List<_ChildEntry> _entries = [];
/// The entry which is currently at the top of the stack.
_ChildEntry? _currentEntry;
bool _disableAnimations = false;
Duration get _effectiveDuration =>
_disableAnimations ? Duration.zero : widget.duration;
@override
void initState() {
super.initState();
_updateEntriesList();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final disableAnimations = MediaQuery.disableAnimationsOf(context);
if (disableAnimations != _disableAnimations) {
_disableAnimations = disableAnimations;
for (final entry in _entries) {
entry.primaryController.duration = _effectiveDuration;
entry.secondaryController.duration = _effectiveDuration;
}
}
}
@override
void didUpdateWidget(AnimatedIndexedStack oldWidget) {
super.didUpdateWidget(oldWidget);
_updateEntriesList();
}
/// In place operation to shift a child entry to the end of the list (the visual front).
///
/// If entry is null, this is a no-op.
void _moveToEnd(List<_ChildEntry> entries, _ChildEntry? entry) {
if (entry == null) return;
entries.remove(entry);
entries.add(entry);
}
/// Inserts an entry as last place in the list and animates it.
///
/// If entry is null, this is a no-op.
void _insertAndAnimate(
List<_ChildEntry> entries,
_ChildEntry? entry,
_ChildAnimationDirection direction,
) {
if (entry == null) return;
_moveToEnd(entries, entry);
switch (direction) {
case _ChildAnimationDirection.primaryForward:
unawaited(entry.primaryController.forward(from: 0));
entry.secondaryController.value = 0;
case _ChildAnimationDirection.primaryReverse:
unawaited(entry.primaryController.reverse(from: 1));
entry.secondaryController.value = 0;
case _ChildAnimationDirection.secondaryForward:
entry.primaryController.value = 1;
unawaited(entry.secondaryController.forward(from: 0));
case _ChildAnimationDirection.secondaryReverse:
entry.primaryController.value = 1;
unawaited(entry.secondaryController.reverse(from: 1));
}
}
/// Updates the list of child entries.
/// Ensures to order the list appropriately and animate entries in and out.
void _updateEntriesList() {
final List<_ChildEntry> entries = [];
final _ChildEntry? previousEntry = _currentEntry;
_ChildEntry? currentEntry;
Widget? currentChild;
if (widget.index != null && widget.children.isNotEmpty) {
currentChild = widget.children[widget.index!];
}
for (final child in widget.children) {
// We find the previous entry by looking for an identical child widget.
// If the children of this Stack share widget types, they must be given unique keys.
final int existingIndex = _entries.indexWhere(
(entry) => Widget.canUpdate(entry.child, child),
);
_ChildEntry? existingEntry;
if (existingIndex != -1) {
existingEntry = _entries[existingIndex];
}
_ChildEntry entry;
if (existingEntry != null) {
// If we find an existing entry, we update its child widget and reuse it.
// This ensures it continues to use the same global key and animation controllers.
existingEntry.child = child;
existingEntry.primaryController.duration = _effectiveDuration;
existingEntry.secondaryController.duration = _effectiveDuration;
entry = existingEntry;
} else {
entry = _newEntry(child);
}
if (currentChild == child) {
currentEntry = entry;
}
entries.add(entry);
}
final bool hasChanged = previousEntry != currentEntry;
final bool previousWasRemoved =
previousEntry != null && !entries.contains(previousEntry);
if (hasChanged) {
if (widget.reverse) {
// When reverse is true, the new child will transition in below the
// old child while its secondary animation and the primary
// animation of the old child are running in reverse. This is similar to
// the transition associated with popping a [PageRoute] to reveal a new
// [PageRoute] below it.
_insertAndAnimate(
entries,
currentEntry,
_ChildAnimationDirection.secondaryReverse,
);
_insertAndAnimate(
entries,
previousEntry,
_ChildAnimationDirection.primaryReverse,
);
if (previousWasRemoved) {
previousEntry.primaryController.addStatusListener((status) {
if (status == AnimationStatus.dismissed) {
setState(() {
_entries.remove(previousEntry);
previousEntry.dispose();
});
}
});
}
} else {
// When reverse is false, the new child will transition in on top of the
// old child while its primary animation and the secondary
// animation of the old child are running forward. This is similar to
// the transition associated with pushing a new [PageRoute] on top of
// another.
_insertAndAnimate(
entries,
previousEntry,
_ChildAnimationDirection.secondaryForward,
);
_insertAndAnimate(
entries,
currentEntry,
_ChildAnimationDirection.primaryForward,
);
if (previousWasRemoved) {
previousEntry.secondaryController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_entries.remove(previousEntry);
previousEntry.dispose();
});
}
});
}
}
} else {
if (widget.reverse) {
_moveToEnd(entries, currentEntry);
_moveToEnd(entries, previousEntry);
} else {
_moveToEnd(entries, previousEntry);
_moveToEnd(entries, currentEntry);
}
}
setState(() {
_entries = entries;
_currentEntry = currentEntry;
});
}
_ChildEntry _newEntry(Widget child) => _ChildEntry(
key: GlobalKey(),
child: child,
primaryController: AnimationController(
duration: _effectiveDuration,
vsync: this,
),
secondaryController: AnimationController(
duration: _effectiveDuration,
vsync: this,
),
);
@override
void dispose() {
for (final entry in _entries) {
entry.dispose();
}
super.dispose();
}
Widget _buildChild(_ChildEntry entry) => AnimatedBuilder(
animation: Listenable.merge([
entry.primaryController,
entry.secondaryController,
]),
builder: (context, child) {
final bool isVisible =
entry.primaryController.isAnimating ||
entry.secondaryController.isAnimating ||
entry == _currentEntry;
return Visibility(
visible: isVisible,
maintainState: true,
child: widget.transitionBuilder(
KeyedSubtree(key: entry.key, child: child!),
entry.primaryController,
entry.secondaryController,
),
);
},
child: entry.child,
);
@override
Widget build(BuildContext context) {
return widget.layoutBuilder(_entries.map(_buildChild).toList());
}
}
@@ -0,0 +1,244 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/string.dart';
import 'package:weblibre/utils/text_field_line_count.dart';
class AutoSuggestTextField extends HookWidget {
final TextEditingController controller;
final String? suggestion;
final TextStyle? style;
final TextStyle? labelStyle;
final InputDecoration? decoration;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final TextCapitalization textCapitalization;
final bool autofocus;
final bool obscureText;
final int? maxLines;
final int? minLines;
final int? maxLength;
final ValueChanged<String>? onChanged;
final VoidCallback? onEditingComplete;
final FormFieldValidator<String>? validator;
final ValueChanged<String>? onSubmitted;
final List<TextInputFormatter>? inputFormatters;
final bool? enabled;
final FocusNode? focusNode;
final Color? cursorColor;
final Color? suggestionHighlightColor;
final bool? enableIMEPersonalizedLearning;
final TapRegionCallback? onTapOutside;
final VoidCallback? onTap;
final bool autocorrect;
final GlobalKey? textFieldKey;
const AutoSuggestTextField({
super.key,
required this.controller,
this.suggestion,
this.style,
this.labelStyle,
this.decoration,
this.keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.autofocus = false,
this.obscureText = false,
this.maxLines = 1,
this.minLines,
this.maxLength,
this.onChanged,
this.onEditingComplete,
this.validator,
this.onSubmitted,
this.inputFormatters,
this.enabled,
this.focusNode,
this.cursorColor,
this.suggestionHighlightColor,
this.enableIMEPersonalizedLearning = true,
this.onTapOutside,
this.onTap,
this.autocorrect = false,
this.textFieldKey,
});
bool _suggestionHasMatch() =>
suggestion != null &&
controller.text.isNotEmpty &&
suggestion!.startsWithIgnoreCase(controller.text);
@override
Widget build(BuildContext context) {
final textFieldKey =
this.textFieldKey ?? useMemoized<GlobalKey>(() => GlobalKey());
final effectiveStyle = style ?? Theme.of(context).textTheme.bodyLarge!;
final showSuggestion = useListenableSelector(controller, () {
if (maxLines != 1) {
final lines = getTextFieldLineCount(
textFieldKey,
controller.text,
effectiveStyle,
);
final suggestionLines = suggestion.mapNotNull(
(suggestion) =>
getTextFieldLineCount(textFieldKey, suggestion, effectiveStyle),
);
return lines == 1 && suggestionLines == 1;
}
return true;
});
final baseDecoration = decoration ?? const InputDecoration();
return Stack(
children: [
if (showSuggestion && suggestion != null)
AbsorbPointer(
child: TextField(
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
decoration: baseDecoration.copyWith(
floatingLabelBehavior: FloatingLabelBehavior.never,
border: InputBorder.none,
suffixIcon: baseDecoration.suffixIcon.mapNotNull(
(_) => const SizedBox.square(dimension: 48),
),
prefixIcon: baseDecoration.prefixIcon.mapNotNull(
(_) => const SizedBox.square(dimension: 48),
),
label: HookBuilder(
builder: (context) {
final text = useListenableSelector(
controller,
() => controller.text,
);
useEffect(() {
TextSelection? lastSelection;
void handleSelectionChange() {
if (lastSelection != controller.selection) {
lastSelection = controller.selection;
if (lastSelection!.start != lastSelection!.end) {
if (_suggestionHasMatch()) {
controller.value = controller.value.copyWith(
text: suggestion,
selection: lastSelection!.expandTo(
TextPosition(offset: suggestion!.length),
),
);
}
}
}
}
controller.addListener(handleSelectionChange);
return () =>
controller.removeListener(handleSelectionChange);
});
if (!_suggestionHasMatch()) {
return const SizedBox.shrink();
}
//TODO: maybe change to Text.rich
return RichText(
maxLines: maxLines,
text: TextSpan(
text: text,
style: effectiveStyle.copyWith(
color: Colors.transparent,
),
children: <TextSpan>[
TextSpan(
text: suggestion!.substring(text.length),
style: effectiveStyle.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
backgroundColor:
suggestionHighlightColor ??
Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.40),
),
),
],
),
);
},
),
alignLabelWithHint: true,
),
),
),
TextFormField(
key: textFieldKey,
controller: controller,
focusNode: focusNode,
decoration: baseDecoration.copyWith(
label: baseDecoration.label ?? const Text(''),
floatingLabelBehavior:
baseDecoration.floatingLabelBehavior ??
FloatingLabelBehavior.never,
),
style: effectiveStyle,
keyboardType: keyboardType,
textInputAction: textInputAction,
textCapitalization: textCapitalization,
autofocus: autofocus,
obscureText: obscureText,
maxLines: maxLines,
minLines: minLines,
maxLength: maxLength,
onChanged: onChanged,
onEditingComplete: onEditingComplete,
validator: validator,
autocorrect: autocorrect,
onFieldSubmitted: onSubmitted.mapNotNull(
(onSubmitted) => (value) {
if (_suggestionHasMatch()) {
onSubmitted(suggestion!);
} else {
onSubmitted(value);
}
},
),
inputFormatters: inputFormatters,
enabled: enabled,
cursorColor: cursorColor,
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning ?? true,
onTapOutside: onTapOutside,
onTap: onTap,
),
],
);
}
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
class BrowserPage extends ConsumerWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPage({
super.key,
this.bottomViewportInset = 0,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final appColors = AppColors.of(context);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
appColors.auraPurple.withValues(alpha: 0.38),
colorScheme.surfaceContainerLowest,
),
Color.alphaBlend(
appColors.auraShadow.withValues(alpha: 0.72),
colorScheme.surface,
),
Color.alphaBlend(
appColors.auraGold.withValues(alpha: 0.34),
colorScheme.surfaceContainerHigh,
),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
top: -70,
left: -120,
child: _BackdropOrb(
width: 400,
height: 400,
color: appColors.auraPurple,
),
),
Positioned(
top: 220,
right: -150,
child: _BackdropOrb(
width: 340,
height: 340,
color: appColors.auraGold,
),
),
Positioned(
bottom: 18,
left: -8,
child: _BackdropOrb(
width: 320,
height: 320,
color: appColors.auraShadowHighlight,
),
),
Positioned.fill(
child: IgnorePointer(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72),
child: ColoredBox(
color: appColors.auraTint.withValues(alpha: 0.12),
),
),
),
),
),
Positioned.fill(child: child),
],
),
);
}
}
class BrowserPageContent extends StatelessWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPageContent({
super.key,
this.bottomViewportInset = 0,
required this.child,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(24, 32, 24, 32 + bottomViewportInset),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight - 64 - bottomViewportInset,
),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: child,
),
),
),
);
},
);
}
}
class BrandHeader extends StatelessWidget {
final ColorScheme colorScheme;
const BrandHeader({super.key, required this.colorScheme});
@override
Widget build(BuildContext context) {
return Container(
width: 112,
height: 112,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
AppColors.brandPurple.withValues(alpha: 0.18),
colorScheme.surfaceContainerHighest,
),
Color.alphaBlend(
AppColors.brandYellow.withValues(alpha: 0.12),
colorScheme.surfaceContainer,
),
],
),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.45),
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 32,
offset: const Offset(0, 18),
),
],
),
child: Center(
child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72),
),
);
}
}
class _BackdropOrb extends StatelessWidget {
final double width;
final double height;
final Color color;
const _BackdropOrb({
required this.width,
required this.height,
required this.color,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: width,
height: height,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
);
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class FailureWidget extends StatelessWidget {
const FailureWidget({
super.key,
this.title,
this.exception,
this.onRetry,
this.compact = false,
});
final String? title;
final dynamic exception;
final VoidCallback? onRetry;
final bool compact;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(title ?? 'Something went wrong'),
subtitle: exception != null
? switch (exception) {
final String string => Text(string),
_ => Text(exception.runtimeType.toString()),
}
: null,
trailing: compact && onRetry != null
? IconButton.outlined(
onPressed: onRetry,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.refresh_outlined),
)
: null,
textColor: Theme.of(context).colorScheme.error,
),
if (!compact && onRetry != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onRetry,
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
label: const Text('Retry'),
icon: const Icon(Icons.refresh_outlined),
),
),
),
],
),
);
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
class QrScannerButton extends HookConsumerWidget {
final void Function(Barcode? scanResult) onScanResult;
const QrScannerButton({super.key, required this.onScanResult});
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () async {
final result = await showDialog<Barcode>(
context: context,
builder: (context) {
return const QrScannerDialog();
},
);
onScanResult(result);
},
icon: const Icon(MdiIcons.barcodeScan),
);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class RoundedBackground extends StatelessWidget {
final Widget child;
final Color? backgroundColor;
const RoundedBackground({
super.key,
required this.child,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
color: backgroundColor ?? Theme.of(context).colorScheme.primary,
),
child: child,
);
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
/// A safe wrapper around [RawImage] that guards against disposed images.
///
/// Checks [EquatableImage.isDisposed] before rendering. When the image
/// is null or disposed, renders [fallback] (defaults to an empty SizedBox
/// matching the requested dimensions).
class SafeRawImage extends StatelessWidget {
final EquatableImage? image;
final double? width;
final double? height;
final BoxFit? fit;
final Widget? fallback;
const SafeRawImage({
super.key,
required this.image,
this.width,
this.height,
this.fit,
this.fallback,
});
@override
Widget build(BuildContext context) {
final uiImage = image?.value;
if (uiImage == null) {
return fallback ?? SizedBox(width: width, height: height);
}
return RawImage(image: uiImage, width: width, height: height, fit: fit);
}
}
@@ -0,0 +1,186 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:nullability/nullability.dart';
class _BadgeWrapper extends StatelessWidget {
final Widget child;
final int? count;
const _BadgeWrapper({required this.child, this.count});
@override
Widget build(BuildContext context) {
return count != null
? Badge.count(
count: count!,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
textColor: Theme.of(context).colorScheme.onPrimaryContainer,
child: child,
)
: child;
}
}
class _GestureWrapper extends StatelessWidget {
final Widget child;
final GestureLongPressCallback? onLongPress;
const _GestureWrapper({required this.child, this.onLongPress});
@override
Widget build(BuildContext context) {
return onLongPress != null
? InkWell(onLongPress: onLongPress, child: child)
: child;
}
}
class SelectableChips<T extends S, S, K> extends StatelessWidget {
final Iterable<T> availableItems;
final List<Widget> prefixListItems;
final S? selectedItem;
final int maxCount;
final bool enableDelete;
final bool sortSelectedFirst;
final ScrollController? scrollController;
final K Function(S item) itemId;
final Widget Function(T item) itemLabel;
final Widget? Function(T item)? itemAvatar;
final String? Function(T item)? itemTooltip;
final int? Function(T item)? itemBadgeCount;
final Color? Function(T item)? itemBackgroundColor;
final Color? selectedBorderColor;
final EdgeInsetsGeometry? Function(T item)? labelPadding;
final Widget Function(Widget child, S item)? itemWrap;
final void Function(T item)? onSelected;
final void Function(T item)? onDeleted;
final void Function(T item)? onLongPress;
const SelectableChips({
required this.itemId,
required this.itemLabel,
this.itemAvatar,
this.itemBadgeCount,
this.itemWrap,
this.itemTooltip,
this.itemBackgroundColor,
this.selectedBorderColor,
this.prefixListItems = const [],
required this.availableItems,
this.selectedItem,
this.maxCount = 25,
this.enableDelete = true,
this.onSelected,
this.onDeleted,
this.onLongPress,
this.sortSelectedFirst = true,
this.scrollController,
this.labelPadding,
super.key,
});
@override
Widget build(BuildContext context) {
var items = availableItems.take(maxCount).toList();
if (sortSelectedFirst) {
if (selectedItem case final T selectedItem) {
final selectedIndex = items.indexWhere(
(item) => itemId(item) == itemId(selectedItem),
);
if (selectedIndex < 0) {
items = [selectedItem, ...items];
} else {
items = [items.removeAt(selectedIndex), ...items];
}
}
}
return FadingScroll(
controller: scrollController,
fadingSize: 15,
builder: (context, controller) {
return ListView.builder(
controller: controller,
//Improve list performance by not rendering outside screen at all
cacheExtent: 0,
scrollDirection: Axis.horizontal,
itemCount: prefixListItems.length + items.length,
itemBuilder: (context, index) {
if (index < prefixListItems.length) {
return Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: prefixListItems[index],
);
}
final item = items[index - prefixListItems.length];
final isSelected =
selectedItem != null &&
itemId(item) == itemId(selectedItem as S);
final child = Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: _BadgeWrapper(
count: itemBadgeCount?.call(item),
child: _GestureWrapper(
onLongPress: onLongPress.mapNotNull(
(callback) =>
() => callback(item),
),
child: FilterChip(
selected: selectedBorderColor == null && isSelected,
showCheckmark: false,
labelPadding: labelPadding?.call(item),
onSelected: (value) {
if (value) {
onSelected?.call(item);
} else {
onDeleted?.call(item);
}
},
onDeleted: enableDelete
? () {
onDeleted?.call(item);
}
: null,
label: itemLabel.call(item),
avatar: itemAvatar?.call(item),
tooltip: itemTooltip?.call(item),
backgroundColor: itemBackgroundColor?.call(item),
side: isSelected && selectedBorderColor != null
? BorderSide(color: selectedBorderColor!, width: 2.0)
: null,
),
),
),
);
return (itemWrap != null) ? itemWrap!(child, item) : child;
},
);
},
);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class ShareTile extends StatelessWidget {
final void Function()? onTap;
final void Function()? onTapQr;
const ShareTile({super.key, this.onTap, this.onTapQr});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.share),
title: const Text('Share link'),
onTap: onTap,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const VerticalDivider(indent: 4, endIndent: 4),
IconButton(icon: const Icon(Icons.qr_code), onPressed: onTapQr),
],
),
);
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class SlidingPillToggle extends StatelessWidget {
final int selectedIndex;
final List<String> labels;
final ValueChanged<int> onChanged;
const SlidingPillToggle({
super.key,
required this.selectedIndex,
required this.labels,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final disableAnimations = MediaQuery.disableAnimationsOf(context);
return Container(
height: 36,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Stack(
children: [
AnimatedAlign(
alignment: Alignment(
-1.0 + (2.0 * selectedIndex / (labels.length - 1)),
0.0,
),
duration: disableAnimations
? Duration.zero
: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
child: FractionallySizedBox(
widthFactor: 1.0 / labels.length,
child: Container(
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(18),
),
),
),
),
Row(
children: [
for (var i = 0; i < labels.length; i++)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(i),
child: Center(
child: Text(
labels[i],
style: theme.textTheme.labelMedium?.copyWith(
color: i == selectedIndex
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fontWeight: i == selectedIndex
? FontWeight.w600
: FontWeight.normal,
),
),
),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:speech_to_text_dialog/speech_to_text_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class SpeechToTextButton extends HookWidget {
final Function(String text) onTextReceived;
const SpeechToTextButton({required this.onTextReceived, super.key});
@override
Widget build(BuildContext context) {
final speechDialog = useMemoized(() => SpeechToTextDialog());
final subscription = useRef<StreamSubscription<String>?>(null);
useEffect(() {
return () {
subscription.value?.cancel().ignore();
speechDialog.dispose();
};
}, [speechDialog]);
Future<void> showSpeechDialog() async {
// Cancel any existing subscription
await subscription.value?.cancel();
// Listen for the next text result
subscription.value = speechDialog.textStream.take(1).listen((text) {
if (text.isNotEmpty) {
onTextReceived(text);
}
});
// Show the dialog
final isServiceAvailable = await speechDialog.showDialog(
locale: PlatformDispatcher.instance.locale.toLanguageTag(),
);
if (!isServiceAvailable) {
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Service is not available');
}
await subscription.value?.cancel();
}
}
return IconButton(onPressed: showSpeechDialog, icon: const Icon(Icons.mic));
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:collection/collection.dart';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
class UriBreadcrumb extends StatelessWidget {
final Uri uri;
final Widget? icon;
final TextStyle? style;
final void Function()? onTooltipTriggered;
const UriBreadcrumb({
super.key,
required this.uri,
this.icon,
this.style,
this.onTooltipTriggered,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: uri.toString(),
onTriggered: onTooltipTriggered,
child: DefaultTextStyle(
style: style ?? DefaultTextStyle.of(context).style,
child: FadingScroll(
fadingSize: 15,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
?icon,
Text(
uri.authority,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (uri.pathSegments.any((s) => s.isNotEmpty))
Text(
' ${uri.pathSegments.whereNot((s) => s.isEmpty).join(' ')}',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
),
],
),
);
},
),
),
);
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
class UrlIcon extends HookConsumerWidget {
final double iconSize;
final List<Uri> urlList;
const UrlIcon(this.urlList, {required this.iconSize, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final icon = useCachedFuture(
() =>
// ignore: discarded_futures
ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList),
[EquatableValue(urlList)],
);
return Skeletonizer(
enabled: icon.connectionState != ConnectionState.done,
child: SizedBox.square(
dimension: iconSize,
child: (icon.data != null)
? RepaintBoundary(
child: SafeRawImage(
image: icon.data?.image,
height: iconSize,
width: iconSize,
fit: BoxFit.fill,
fallback: Icon(MdiIcons.web, size: iconSize),
),
)
: Icon(MdiIcons.web, size: iconSize),
),
);
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class UrlListTile extends StatelessWidget {
final String title;
final Uri uri;
final Widget? leading;
final Widget? trailing;
final Color? borderColor;
final VoidCallback? onTap;
const UrlListTile({
super.key,
required this.title,
required this.uri,
this.leading,
this.trailing,
this.borderColor,
this.onTap,
});
static const iconSize = 32.0;
static const _borderRadius = BorderRadius.all(Radius.circular(12.0));
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
decoration: BoxDecoration(
borderRadius: _borderRadius,
border: borderColor != null
? Border(right: BorderSide(color: borderColor!, width: 4.0))
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: _borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
borderRadius: _borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 10.0,
bottom: 10.0,
right: 12.0,
),
child: Row(
children: [
leading ??
RepaintBoundary(child: UrlIcon([uri], iconSize: iconSize)),
const SizedBox(width: 14.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3.0),
UriBreadcrumb(
uri: uri,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8.0),
trailing!,
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/presentation/controllers/website_title.dart';
import 'package:weblibre/presentation/widgets/rounded_text.dart';
class WebsiteFeedMenuButton extends HookConsumerWidget {
final String tabId;
const WebsiteFeedMenuButton(this.tabId, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final feedsAsync = ref.watch(websiteFeedProviderProvider(tabId));
return Skeletonizer(
enabled: feedsAsync.isLoading && feedsAsync.value?.value == null,
child: feedsAsync.when(
skipLoadingOnReload: true,
data: (feeds) {
if (feeds.value.isEmpty) {
return const SizedBox.shrink();
}
return MenuItemButton(
leadingIcon: const Icon(Icons.rss_feed),
closeOnActivate: false,
trailingIcon: RoundedBackground(
child: Text(
feeds.value!.length.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
onPressed: () async {
await SelectFeedDialogRoute(
feedsJson: jsonEncode(
feeds.value!.map((feed) => feed.toString()).toList(),
),
).push(context);
},
child: const Text('Available Web Feeds'),
);
},
error: (error, stackTrace) {
return const SizedBox.shrink();
//Will be dispalyed on title already
// return FailureWidget(
// title: error.toString(),
// onRetry: () => ref.refresh(pageInfoProvider(url)),
// );
},
loading: () => const MenuItemButton(
leadingIcon: Icon(Icons.rss_feed),
child: Text('Available Web Feeds'),
),
),
);
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/presentation/controllers/website_title.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class WebsiteTitleTile extends HookConsumerWidget {
final TabState initialTabState;
const WebsiteTitleTile(this.initialTabState, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pageInfoAsync = ref.watch(completePageInfoProvider(initialTabState));
return Skeletonizer(
enabled: pageInfoAsync.isLoading,
child: pageInfoAsync.when(
skipLoadingOnReload: true,
data: (info) {
return ListTile(
leading: RepaintBoundary(
child:
info.favicon.mapNotNull(
(favicon) => SafeRawImage(
image: favicon.image,
height: 24,
width: 24,
fallback: const Icon(MdiIcons.web, size: 24),
),
) ??
const Icon(MdiIcons.web, size: 24),
),
contentPadding: EdgeInsets.zero,
title: Text(
info.title.whenNotEmpty ?? info.url.authority,
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
subtitle: UriBreadcrumb(uri: initialTabState.url),
);
},
error: (error, stackTrace) {
return FailureWidget(
title: error.toString(),
onRetry: () => ref.refresh(
pageInfoProvider(initialTabState.url, isImageRequest: false),
),
);
},
loading: () => ListTile(
leading: SafeRawImage(
image: initialTabState.favicon?.image,
height: 24,
width: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(initialTabState.titleOrAuthority),
subtitle: UriBreadcrumb(uri: initialTabState.url),
),
),
);
}
}