feat: add flutter mobile console and refine login ui
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(18),
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Card(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap == null) {
|
||||
return card;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
this.title = '暂无数据',
|
||||
this.description = '当前没有可展示内容',
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.inbox_rounded, color: Color(0xFF2563EB), size: 28),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonal(
|
||||
onPressed: onAction,
|
||||
child: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class AppErrorCard extends StatelessWidget {
|
||||
const AppErrorCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.error_outline_rounded, color: Color(0xFFDC2626)),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppSearchBar extends StatelessWidget {
|
||||
const AppSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
this.onSubmitted,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onSubmitted: onSubmitted,
|
||||
onChanged: onChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: controller.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onChanged?.call('');
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class MetricCard extends StatelessWidget {
|
||||
const MetricCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.description,
|
||||
this.color = const Color(0xFF2563EB),
|
||||
this.trendValue,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String description;
|
||||
final Color color;
|
||||
final double? trendValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (trendValue ?? 0).clamp(0.05, 1.0);
|
||||
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
value: progress,
|
||||
color: color,
|
||||
backgroundColor: color.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileHeader extends StatelessWidget {
|
||||
const MobileHeader({
|
||||
super.key,
|
||||
required this.eyebrow,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.userInitials = 'L',
|
||||
this.onNotificationsPressed,
|
||||
this.onProfilePressed,
|
||||
});
|
||||
|
||||
final String eyebrow;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final String userInitials;
|
||||
final VoidCallback? onNotificationsPressed;
|
||||
final VoidCallback? onProfilePressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
eyebrow,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: onNotificationsPressed,
|
||||
icon: const Icon(Icons.notifications_none_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onProfilePressed,
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: const Color(0xFFE0ECFF),
|
||||
foregroundColor: const Color(0xFF2563EB),
|
||||
child: Text(
|
||||
userInitials,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class SkeletonCard extends StatefulWidget {
|
||||
const SkeletonCard({
|
||||
super.key,
|
||||
this.height = 120,
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<SkeletonCard> createState() => _SkeletonCardState();
|
||||
}
|
||||
|
||||
class _SkeletonCardState extends State<SkeletonCard> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
final opacity = 0.35 + (_controller.value * 0.4);
|
||||
return AppCard(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE2E8F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
|
||||
class StatusBadge extends StatelessWidget {
|
||||
const StatusBadge({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.label,
|
||||
this.context,
|
||||
});
|
||||
|
||||
final Object? status;
|
||||
final String? label;
|
||||
final String? context;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tone = toneForStatus(
|
||||
value: status is int ? status as int : null,
|
||||
keyword: status?.toString(),
|
||||
context: this.context,
|
||||
);
|
||||
final style = _styleFor(tone);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: style.background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: style.border),
|
||||
),
|
||||
child: Text(
|
||||
label ?? status?.toString() ?? '--',
|
||||
style: TextStyle(
|
||||
color: style.foreground,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_BadgeStyle _styleFor(StatusTone tone) {
|
||||
switch (tone) {
|
||||
case StatusTone.green:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFECFDF5),
|
||||
foreground: Color(0xFF047857),
|
||||
border: Color(0xFFA7F3D0),
|
||||
);
|
||||
case StatusTone.blue:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFEFF6FF),
|
||||
foreground: Color(0xFF1D4ED8),
|
||||
border: Color(0xFFBFDBFE),
|
||||
);
|
||||
case StatusTone.yellow:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFFFBEB),
|
||||
foreground: Color(0xFFB45309),
|
||||
border: Color(0xFFFDE68A),
|
||||
);
|
||||
case StatusTone.red:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFEF2F2),
|
||||
foreground: Color(0xFFB91C1C),
|
||||
border: Color(0xFFFECACA),
|
||||
);
|
||||
case StatusTone.orange:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFFF7ED),
|
||||
foreground: Color(0xFFC2410C),
|
||||
border: Color(0xFFFED7AA),
|
||||
);
|
||||
case StatusTone.indigo:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFEEF2FF),
|
||||
foreground: Color(0xFF4338CA),
|
||||
border: Color(0xFFC7D2FE),
|
||||
);
|
||||
case StatusTone.gray:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFF1F5F9),
|
||||
foreground: Color(0xFF475569),
|
||||
border: Color(0xFFE2E8F0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeStyle {
|
||||
const _BadgeStyle({
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.border,
|
||||
});
|
||||
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final Color border;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user