feat: add flutter mobile console and refine login ui

This commit is contained in:
2026-05-15 00:24:58 +08:00
parent d69c7d015e
commit 50b207415a
87 changed files with 9822 additions and 157 deletions
+35
View File
@@ -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),
),
),
),
);
}
}
+72
View File
@@ -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),
),
),
),
);
},
);
}
}
+102
View File
@@ -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;
}