mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-07 07:15:47 -06:00
Initial commit
This commit is contained in:
15
lib/core/widgets/coming_soon_placeholder.dart
Normal file
15
lib/core/widgets/coming_soon_placeholder.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ComingSoon extends StatelessWidget {
|
||||
const ComingSoon({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Coming Soon\u2122",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/core/widgets/confirm_button.dart
Normal file
70
lib/core/widgets/confirm_button.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ElevatedConfirmationButton extends StatefulWidget {
|
||||
factory ElevatedConfirmationButton.icon(BuildContext context,
|
||||
{required void Function() onPressed, required Icon icon, required Widget label}) {
|
||||
final double scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1;
|
||||
final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!;
|
||||
return ElevatedConfirmationButton(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[icon, SizedBox(width: gap), Flexible(child: label)],
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
const ElevatedConfirmationButton({
|
||||
Key? key,
|
||||
this.color,
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.confirmWidget = const Text("Confirm?"),
|
||||
}) : super(key: key);
|
||||
|
||||
final Color? color;
|
||||
final void Function()? onPressed;
|
||||
final Widget child;
|
||||
final Widget confirmWidget;
|
||||
@override
|
||||
State<ElevatedConfirmationButton> createState() => _ElevatedConfirmationButtonState();
|
||||
}
|
||||
|
||||
class _ElevatedConfirmationButtonState extends State<ElevatedConfirmationButton> {
|
||||
bool _clickedOnce = false;
|
||||
double? _originalWidth;
|
||||
final GlobalKey _originalWidgetKey = GlobalKey();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_clickedOnce) {
|
||||
return ElevatedButton(
|
||||
key: _originalWidgetKey,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(widget.color),
|
||||
),
|
||||
onPressed: () {
|
||||
_originalWidth =
|
||||
(_originalWidgetKey.currentContext?.findRenderObject() as RenderBox).size.width;
|
||||
setState(() => _clickedOnce = true);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
} else {
|
||||
return Builder(builder: (context) {
|
||||
return SizedBox(
|
||||
width: _originalWidth,
|
||||
child: ElevatedButton(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(widget.color),
|
||||
),
|
||||
onPressed: widget.onPressed,
|
||||
child: widget.confirmWidget,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/core/widgets/documents_list_loading_widget.dart
Normal file
86
lib/core/widgets/documents_list_loading_widget.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class DocumentsListLoadingWidget extends StatelessWidget {
|
||||
static const tags = [" ", " ", " "];
|
||||
static const titleLengths = <double>[double.infinity, 150.0, 200.0];
|
||||
static const correspondentLengths = <double>[200.0, 300.0, 150.0];
|
||||
static const fontSize = 16.0;
|
||||
|
||||
const DocumentsListLoadingWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.grey[300]!
|
||||
: Colors.grey[900]!,
|
||||
highlightColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.grey[100]!
|
||||
: Colors.grey[600]!,
|
||||
child: ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
final r = Random(index);
|
||||
final tagCount = r.nextInt(tags.length + 1);
|
||||
final correspondentLength = correspondentLengths[
|
||||
r.nextInt(correspondentLengths.length - 1)];
|
||||
final titleLength =
|
||||
titleLengths[r.nextInt(titleLengths.length - 1)];
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
leading: Container(
|
||||
color: Colors.white,
|
||||
height: 50,
|
||||
width: 50,
|
||||
),
|
||||
title: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
width: correspondentLength,
|
||||
height: fontSize,
|
||||
color: Colors.white,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
height: fontSize,
|
||||
width: titleLength,
|
||||
color: Colors.white,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 2.0,
|
||||
children: List.generate(
|
||||
tagCount,
|
||||
(index) => Chip(
|
||||
label: Text(tags[r.nextInt(tags.length)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: 25,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/core/widgets/empty_state.dart
Normal file
45
lib/core/widgets/empty_state.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget? bottomChild;
|
||||
|
||||
const EmptyState({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.bottomChild,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: size.height / 3,
|
||||
width: size.width / 3,
|
||||
child: SvgPicture.asset("assets/images/empty-state.svg"),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (bottomChild != null) ...[bottomChild!] else ...[]
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/core/widgets/expandable_floating_action_button.dart
Normal file
215
lib/core/widgets/expandable_floating_action_button.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class ExpandableFloatingActionButton extends StatefulWidget {
|
||||
const ExpandableFloatingActionButton({
|
||||
super.key,
|
||||
this.initialOpen,
|
||||
required this.distance,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final bool? initialOpen;
|
||||
final double distance;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
State<ExpandableFloatingActionButton> createState() =>
|
||||
_ExpandableFloatingActionButtonState();
|
||||
}
|
||||
|
||||
class _ExpandableFloatingActionButtonState
|
||||
extends State<ExpandableFloatingActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _expandAnimation;
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_open = widget.initialOpen ?? false;
|
||||
_controller = AnimationController(
|
||||
value: _open ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
_expandAnimation = CurvedAnimation(
|
||||
curve: Curves.fastOutSlowIn,
|
||||
reverseCurve: Curves.easeOutQuad,
|
||||
parent: _controller,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() {
|
||||
_open = !_open;
|
||||
if (_open) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildTapToCloseFab(),
|
||||
..._buildExpandingActionButtons(),
|
||||
_buildTapToOpenFab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTapToCloseFab() {
|
||||
return SizedBox(
|
||||
width: 56.0,
|
||||
height: 56.0,
|
||||
child: Center(
|
||||
child: Material(
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 4.0,
|
||||
child: InkWell(
|
||||
onTap: _toggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildExpandingActionButtons() {
|
||||
final children = <Widget>[];
|
||||
final count = widget.children.length;
|
||||
final step = 90.0 / (count - 1);
|
||||
for (var i = 0, angleInDegrees = 0.0;
|
||||
i < count;
|
||||
i++, angleInDegrees += step) {
|
||||
children.add(
|
||||
_ExpandingActionButton(
|
||||
directionInDegrees: angleInDegrees,
|
||||
maxDistance: widget.distance,
|
||||
progress: _expandAnimation,
|
||||
child: widget.children[i],
|
||||
),
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
Widget _buildTapToOpenFab() {
|
||||
return IgnorePointer(
|
||||
ignoring: _open,
|
||||
child: AnimatedContainer(
|
||||
transformAlignment: Alignment.center,
|
||||
transform: Matrix4.diagonal3Values(
|
||||
_open ? 0.7 : 1.0,
|
||||
_open ? 0.7 : 1.0,
|
||||
1.0,
|
||||
),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||
child: AnimatedOpacity(
|
||||
opacity: _open ? 0.0 : 1.0,
|
||||
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: FloatingActionButton(
|
||||
onPressed: _toggle,
|
||||
child: const Icon(Icons.create),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class _ExpandingActionButton extends StatelessWidget {
|
||||
const _ExpandingActionButton({
|
||||
required this.directionInDegrees,
|
||||
required this.maxDistance,
|
||||
required this.progress,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final double directionInDegrees;
|
||||
final double maxDistance;
|
||||
final Animation<double> progress;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: progress,
|
||||
builder: (context, child) {
|
||||
final offset = Offset.fromDirection(
|
||||
directionInDegrees * (math.pi / 180.0),
|
||||
progress.value * maxDistance,
|
||||
);
|
||||
return Positioned(
|
||||
right: 4.0 + offset.dx,
|
||||
bottom: 4.0 + offset.dy,
|
||||
child: Transform.rotate(
|
||||
angle: (1.0 - progress.value) * math.pi / 2,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: FadeTransition(
|
||||
opacity: progress,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ExpandableActionButton extends StatelessWidget {
|
||||
const ExpandableActionButton({
|
||||
super.key,
|
||||
this.color,
|
||||
this.onPressed,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final Widget icon;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: icon,
|
||||
style: ButtonStyle(
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
backgroundColor: MaterialStateProperty.all(color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/core/widgets/highlighted_text.dart
Normal file
125
lib/core/widgets/highlighted_text.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HighlightedText extends StatelessWidget {
|
||||
final String text;
|
||||
final List<String> highlights;
|
||||
final Color? color;
|
||||
final TextStyle? style;
|
||||
final bool caseSensitive;
|
||||
|
||||
final TextAlign textAlign;
|
||||
final TextDirection? textDirection;
|
||||
final TextOverflow overflow;
|
||||
final double textScaleFactor;
|
||||
final int? maxLines;
|
||||
final StrutStyle? strutStyle;
|
||||
final TextWidthBasis textWidthBasis;
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
|
||||
const HighlightedText({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.highlights,
|
||||
this.style,
|
||||
this.color = Colors.yellowAccent,
|
||||
this.caseSensitive = true,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.textDirection = TextDirection.ltr,
|
||||
this.overflow = TextOverflow.clip,
|
||||
this.textScaleFactor = 1.0,
|
||||
this.maxLines,
|
||||
this.strutStyle,
|
||||
this.textWidthBasis = TextWidthBasis.parent,
|
||||
this.textHeightBehavior,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (text.isEmpty || highlights.isEmpty || highlights.contains('')) {
|
||||
return SelectableText.rich(
|
||||
_normalSpan(text, context),
|
||||
key: key,
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
textScaleFactor: textScaleFactor,
|
||||
maxLines: maxLines,
|
||||
strutStyle: strutStyle,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
style: TextStyle(overflow: overflow),
|
||||
);
|
||||
}
|
||||
|
||||
return SelectableText.rich(
|
||||
TextSpan(children: _buildChildren(context)),
|
||||
key: key,
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
textScaleFactor: textScaleFactor,
|
||||
maxLines: maxLines,
|
||||
strutStyle: strutStyle,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
style: TextStyle(overflow: overflow),
|
||||
);
|
||||
}
|
||||
|
||||
List<TextSpan> _buildChildren(BuildContext context) {
|
||||
List<TextSpan> _spans = [];
|
||||
int _start = 0;
|
||||
|
||||
String _text = caseSensitive ? text : text.toLowerCase();
|
||||
List<String> _highlights =
|
||||
caseSensitive ? highlights : highlights.map((e) => e.toLowerCase()).toList();
|
||||
|
||||
while (true) {
|
||||
Map<int, String> _highlightsMap = {}; //key (index), value (highlight).
|
||||
|
||||
for (final h in _highlights) {
|
||||
final idx = _text.indexOf(h, _start);
|
||||
if (idx >= 0) {
|
||||
_highlightsMap.putIfAbsent(_text.indexOf(h, _start), () => h);
|
||||
}
|
||||
}
|
||||
|
||||
if (_highlightsMap.isNotEmpty) {
|
||||
int _currentIndex = _highlightsMap.keys.reduce(min);
|
||||
String _currentHighlight = text.substring(
|
||||
_currentIndex,
|
||||
_currentIndex + _highlightsMap[_currentIndex]!.length,
|
||||
);
|
||||
|
||||
if (_currentIndex == _start) {
|
||||
_spans.add(_highlightSpan(_currentHighlight));
|
||||
_start += _currentHighlight.length;
|
||||
} else {
|
||||
_spans.add(_normalSpan(text.substring(_start, _currentIndex), context));
|
||||
_spans.add(_highlightSpan(_currentHighlight));
|
||||
_start = _currentIndex + _currentHighlight.length;
|
||||
}
|
||||
} else {
|
||||
_spans.add(_normalSpan(text.substring(_start, text.length), context));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return _spans;
|
||||
}
|
||||
|
||||
TextSpan _highlightSpan(String value) {
|
||||
return TextSpan(
|
||||
text: value,
|
||||
style: style?.copyWith(
|
||||
backgroundColor: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _normalSpan(String value, BuildContext context) {
|
||||
return TextSpan(
|
||||
text: value,
|
||||
style: style ?? Theme.of(context).textTheme.bodyText2,
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/core/widgets/offline_banner.dart
Normal file
30
lib/core/widgets/offline_banner.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class OfflineBanner extends StatelessWidget with PreferredSizeWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Theme.of(context).disabledColor,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Icon(
|
||||
Icons.cloud_off,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
Text(S.of(context).genericMessageOfflineText),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(24);
|
||||
}
|
||||
23
lib/core/widgets/offline_widget.dart
Normal file
23
lib/core/widgets/offline_widget.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class OfflineWidget extends StatelessWidget {
|
||||
const OfflineWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.mood_bad, size: (Theme.of(context).iconTheme.size ?? 24) * 3),
|
||||
Text(
|
||||
S.of(context).offlineWidgetText,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/core/widgets/paperless_logo.dart
Normal file
23
lib/core/widgets/paperless_logo.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class PaperlessLogo extends StatelessWidget {
|
||||
final double? height;
|
||||
final double? width;
|
||||
const PaperlessLogo({Key? key, this.height, this.width}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: height ?? Theme.of(context).iconTheme.size ?? 32,
|
||||
maxWidth: width ?? Theme.of(context).iconTheme.size ?? 32,
|
||||
),
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SvgPicture.asset(
|
||||
"assets/logo/paperless_ng_logo_light.svg",
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user