feat(proj): init
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Native iOS spinner.
|
||||
class AppSpinner extends StatelessWidget {
|
||||
const AppSpinner({super.key, this.radius = 12, this.color});
|
||||
|
||||
final double radius;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoActivityIndicator(
|
||||
radius: radius,
|
||||
color: color == null ? null : AppColors.of(context, color!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS progress bar: 4pt capsule track.
|
||||
class AppProgressBar extends StatelessWidget {
|
||||
const AppProgressBar({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.color = AppColors.accent,
|
||||
this.height = 4,
|
||||
});
|
||||
|
||||
/// 0..1
|
||||
final double value;
|
||||
final Color color;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => Stack(
|
||||
children: [
|
||||
Container(color: AppColors.of(context, AppColors.systemGray5)),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
width: constraints.maxWidth * value.clamp(0.0, 1.0),
|
||||
color: AppColors.of(context, color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Pulsing placeholder block used while content loads.
|
||||
class AppSkeleton extends StatefulWidget {
|
||||
const AppSkeleton({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16,
|
||||
this.radius = AppRadii.sm,
|
||||
});
|
||||
|
||||
const AppSkeleton.circle({super.key, required double size})
|
||||
: width = size,
|
||||
height = size,
|
||||
radius = AppRadii.capsule;
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<AppSkeleton> createState() => _AppSkeletonState();
|
||||
}
|
||||
|
||||
class _AppSkeletonState extends State<AppSkeleton> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.45, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
),
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.systemGray5),
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton shaped like an [AppListTile] row.
|
||||
class AppSkeletonRow extends StatelessWidget {
|
||||
const AppSkeletonRow({super.key, this.hasLeading = true});
|
||||
|
||||
final bool hasLeading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.gutter,
|
||||
vertical: AppSpacing.s3,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasLeading) ...[
|
||||
const AppSkeleton.circle(size: 29),
|
||||
const SizedBox(width: AppSpacing.s3),
|
||||
],
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppSkeleton(width: 140, height: 15),
|
||||
SizedBox(height: AppSpacing.s2),
|
||||
AppSkeleton(width: 90, height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
const AppSkeleton(width: 56, height: 15),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_button.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_icon.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
import 'package:please_pay_me/ui/feedback/app_progress.dart';
|
||||
|
||||
/// Centered placeholder for empty collections.
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.icon = CupertinoIcons.tray,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? message;
|
||||
final IconData icon;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s6,
|
||||
vertical: AppSpacing.s7,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(icon, size: 44, color: AppColors.tertiaryLabel),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
AppText.headline(title, textAlign: TextAlign.center),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: AppSpacing.s1),
|
||||
AppText.subhead(message!, textAlign: TextAlign.center),
|
||||
],
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppButton(
|
||||
label: actionLabel!,
|
||||
size: AppButtonSize.medium,
|
||||
style: AppButtonStyle.tinted,
|
||||
expanded: false,
|
||||
onPressed: onAction,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure placeholder with a retry affordance.
|
||||
class AppErrorView extends StatelessWidget {
|
||||
const AppErrorView({super.key, required this.message, this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppEmptyState(
|
||||
icon: CupertinoIcons.exclamationmark_triangle,
|
||||
title: 'Не получилось загрузить',
|
||||
message: message,
|
||||
actionLabel: onRetry == null ? null : 'Повторить',
|
||||
onAction: onRetry,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLoadingView extends StatelessWidget {
|
||||
const AppLoadingView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.s7),
|
||||
child: Center(child: AppSpinner()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// iOS has no SnackBar — the platform idiom is a floating blurred capsule
|
||||
/// (AirPods / Silent-mode style). Kept dismiss-free and auto-hiding.
|
||||
class AppToast extends StatelessWidget {
|
||||
const AppToast({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon = CupertinoIcons.check_mark_circled_solid,
|
||||
this.tint = AppColors.accent,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final IconData? icon;
|
||||
final Color tint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s4,
|
||||
vertical: AppSpacing.s3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.groupedSurface).withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x1F000000), blurRadius: 24, offset: Offset(0, 8)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20, color: AppColors.of(context, tint)),
|
||||
const SizedBox(width: AppSpacing.s2),
|
||||
],
|
||||
Flexible(child: AppText.subhead(message, color: AppColors.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showAppToast(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
IconData? icon = CupertinoIcons.check_mark_circled_solid,
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) async {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
final entry = OverlayEntry(
|
||||
builder: (ctx) => Positioned(
|
||||
left: AppSpacing.s5,
|
||||
right: AppSpacing.s5,
|
||||
bottom: MediaQuery.of(ctx).padding.bottom + AppSpacing.s7,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Center(child: AppToast(message: message, icon: icon)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry);
|
||||
await Future<void>.delayed(duration);
|
||||
entry.remove();
|
||||
}
|
||||
Reference in New Issue
Block a user