feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+56
View File
@@ -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),
),
],
),
),
),
);
}
}
+90
View File
@@ -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()),
);
}
}
+77
View File
@@ -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();
}