fix device bind status
This commit is contained in:
parent
90609d97a3
commit
f43fd80e3a
@ -26,6 +26,10 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要相机权限来拍照传图到徽章设备</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要相册权限来选择图片传图到徽章设备</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
|
||||
@ -143,7 +143,6 @@ class _AuthInterceptor extends Interceptor {
|
||||
'/auth/phone-login/',
|
||||
'/auth/refresh/',
|
||||
'/version/check/',
|
||||
'/devices/query-by-mac/',
|
||||
];
|
||||
|
||||
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
class ApiConfig {
|
||||
/// 后端服务器地址
|
||||
static const String baseUrl = 'https://qiyuan-rtc-api.airlabs.art';
|
||||
/// 本地开发地址(Web 调试用)
|
||||
static const String _localUrl = 'http://192.168.124.8:8000';
|
||||
|
||||
/// 线上地址(APP 用)
|
||||
static const String _prodUrl = 'https://qiyuan-rtc-api.airlabs.art';
|
||||
|
||||
/// 根据运行环境自动选择:Web → 本地,APP → 线上
|
||||
static String get baseUrl => kIsWeb ? _localUrl : _prodUrl;
|
||||
|
||||
/// 一键授权登录专用域名(HTTPS,用于阿里云号码认证)
|
||||
static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art';
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
@ -10,13 +11,23 @@ import '../../pages/product_selection_page.dart';
|
||||
import '../../pages/profile/profile_page.dart';
|
||||
import '../../pages/webview_page.dart';
|
||||
import '../../pages/wifi_config_page.dart';
|
||||
import '../../features/badge/presentation/pages/badge_basic_control_page.dart';
|
||||
import '../../features/badge/presentation/pages/badge_control_page.dart';
|
||||
import '../../features/badge/presentation/pages/badge_home_page.dart';
|
||||
import '../../features/badge/presentation/pages/badge_transfer_page.dart';
|
||||
import '../../features/device/data/datasources/device_remote_data_source.dart';
|
||||
import '../../theme/product_theme.dart';
|
||||
import '../network/token_manager.dart';
|
||||
|
||||
part 'app_router.g.dart';
|
||||
|
||||
/// 产品代码 → 路由 + ProductType 的映射
|
||||
const _productCodeRoutes = {
|
||||
'KPBL-ON': (route: '/device-control', type: ProductType.capybara),
|
||||
'DZBJ-ON': (route: '/badge-control', type: ProductType.badgeAi),
|
||||
'DZBJ-OFF': (route: '/badge-basic-control', type: ProductType.badgeBasic),
|
||||
};
|
||||
|
||||
@riverpod
|
||||
GoRouter goRouter(Ref ref) {
|
||||
final tokenManager = ref.watch(tokenManagerProvider);
|
||||
@ -32,6 +43,37 @@ GoRouter goRouter(Ref ref) {
|
||||
return '/login';
|
||||
}
|
||||
if (hasToken && isLoginRoute) {
|
||||
// 登录成功 → 跳到最近使用的设备业务页
|
||||
try {
|
||||
final dataSource = ref.read(deviceRemoteDataSourceProvider);
|
||||
final devices = await dataSource.getMyDevices();
|
||||
debugPrint('[Router] 已绑定设备数: ${devices.length}');
|
||||
if (devices.isNotEmpty) {
|
||||
// 按 last_online_at 降序,取最近使用的设备
|
||||
devices.sort((a, b) {
|
||||
final ta = a.device.lastOnlineAt ?? '';
|
||||
final tb = b.device.lastOnlineAt ?? '';
|
||||
return tb.compareTo(ta);
|
||||
});
|
||||
final recent = devices.first;
|
||||
final dt = recent.device.deviceType;
|
||||
final dti = recent.device.deviceTypeInfo;
|
||||
debugPrint('[Router] 最近设备 sn=${recent.device.sn}');
|
||||
debugPrint('[Router] deviceType=$dt');
|
||||
debugPrint('[Router] deviceTypeInfo=$dti');
|
||||
final resolvedDt = dt ?? dti;
|
||||
final code = resolvedDt?.productCode ?? '';
|
||||
debugPrint('[Router] productCode=$code');
|
||||
final mapping = _productCodeRoutes[code];
|
||||
debugPrint('[Router] mapping=$mapping → route=${mapping?.route}');
|
||||
if (mapping != null) {
|
||||
ref.read(currentProductTypeProvider.notifier).set(mapping.type);
|
||||
return mapping.route;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Router] 获取设备失败: $e');
|
||||
}
|
||||
return '/home';
|
||||
}
|
||||
return null;
|
||||
@ -69,6 +111,10 @@ GoRouter goRouter(Ref ref) {
|
||||
path: '/badge-control',
|
||||
builder: (context, state) => const BadgeControlPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/badge-basic-control',
|
||||
builder: (context, state) => const BadgeBasicControlPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/badge',
|
||||
builder: (context, state) => const BadgeHomePage(),
|
||||
|
||||
@ -48,4 +48,4 @@ final class GoRouterProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$goRouterHash() => r'25447f0e21cf92fb17956c2d137db4fe19e43338';
|
||||
String _$goRouterHash() => r'276ed56c903c6bd5bb43569f7b8f58103d73198c';
|
||||
|
||||
@ -219,7 +219,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
|
||||
debugPrint('[Login] tokenLogin 结果: $success');
|
||||
if (success && mounted) {
|
||||
await _navigateAfterLogin();
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@ -274,25 +274,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
.read(authControllerProvider.notifier)
|
||||
.codeLogin(_phoneController.text, _codeController.text);
|
||||
if (success && mounted) {
|
||||
await _navigateAfterLogin();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateAfterLogin() async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final devices = await ref.read(deviceControllerProvider.future);
|
||||
if (!mounted) return;
|
||||
if (devices.isNotEmpty) {
|
||||
debugPrint('[Login] User has ${devices.length} device(s), navigating to device control');
|
||||
context.go('/device-control');
|
||||
} else {
|
||||
debugPrint('[Login] No devices, navigating to home');
|
||||
context.go('/home');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Login] Device check failed: $e');
|
||||
if (mounted) context.go('/home');
|
||||
// 登录成功后跳到 /login,触发 router redirect 统一处理跳转
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,424 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../core/network/api_config.dart';
|
||||
import '../../../../pages/profile/profile_page.dart';
|
||||
import '../../../../theme/product_theme.dart';
|
||||
import '../../../../widgets/animated_gradient_background.dart';
|
||||
|
||||
/// 普通电子吧唧 (DZBJ-OFF) 业务主页
|
||||
class BadgeBasicControlPage extends ConsumerStatefulWidget {
|
||||
const BadgeBasicControlPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BadgeBasicControlPage> createState() =>
|
||||
_BadgeBasicControlPageState();
|
||||
}
|
||||
|
||||
class _BadgeBasicControlPageState extends ConsumerState<BadgeBasicControlPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int _currentTab = 0;
|
||||
late AnimationController _floatController;
|
||||
late Animation<double> _floatAnimation;
|
||||
|
||||
String? _lastImageUrl;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic));
|
||||
_floatController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
)..repeat(reverse: true);
|
||||
_floatAnimation = Tween<double>(begin: -8, end: 8).animate(
|
||||
CurvedAnimation(parent: _floatController, curve: Curves.easeInOut),
|
||||
);
|
||||
_loadLastImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_floatController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadLastImage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('access_token');
|
||||
final resp = await http.get(
|
||||
Uri.parse('${ApiConfig.fullBaseUrl}/badge/history/'),
|
||||
headers: {if (token != null) 'Authorization': 'Bearer $token'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (resp.statusCode == 200) {
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? {};
|
||||
final images = (data['images'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((img) =>
|
||||
img['generation_status'] == 'completed' &&
|
||||
(img['image_url'] as String?)?.isNotEmpty == true)
|
||||
.toList();
|
||||
if (images.isNotEmpty && mounted) {
|
||||
setState(() => _lastImageUrl = images.first['image_url'] as String);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productTheme = ref.watch(currentProductThemeProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Stack(
|
||||
children: [
|
||||
const AnimatedGradientBackground(),
|
||||
|
||||
IndexedStack(
|
||||
index: _currentTab,
|
||||
children: [
|
||||
_buildHomePage(productTheme),
|
||||
const ProfilePage(),
|
||||
],
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 12,
|
||||
child: _buildBottomNavBar(productTheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomePage(ProductThemeData productTheme) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _floatAnimation,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(0, _floatAnimation.value),
|
||||
child: child,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_BadgePreviewCircle(
|
||||
imageUrl: _lastImageUrl,
|
||||
loading: _loading,
|
||||
accentColor: productTheme.accentColor,
|
||||
size: 240,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_loading && _lastImageUrl == null)
|
||||
Text(
|
||||
'点击右上角「传图」上传你的第一张图',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: productTheme.accentColor.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_GlassIconButton(
|
||||
onTap: () => context.push('/product-selection'),
|
||||
child: SvgPicture.asset(
|
||||
'assets/www/icons/icon-switch.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF4B5563),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
_GlassPillButton(
|
||||
onTap: () => context.push('/badge'),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'传图',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: productTheme.accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.send_rounded,
|
||||
size: 16, color: productTheme.accentColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomNavBar(ProductThemeData productTheme) {
|
||||
return Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
width: 180,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.8)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF4B5563).withOpacity(0.08),
|
||||
offset: const Offset(0, 10),
|
||||
blurRadius: 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildNavItem(0, 'assets/www/icons/icon-home-pixel.svg',
|
||||
Icons.home, productTheme),
|
||||
_buildNavItem(1, 'assets/www/icons/icon-user-pixel.svg',
|
||||
Icons.person, productTheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
int index, String iconPath, IconData fallback, ProductThemeData theme) {
|
||||
final isActive = _currentTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _currentTab = index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive ? theme.buttonGradient : null,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.accentColor.withOpacity(0.4),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 15,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: SvgPicture.asset(
|
||||
iconPath,
|
||||
width: isActive ? 28 : 26,
|
||||
height: isActive ? 28 : 26,
|
||||
colorFilter: ColorFilter.mode(
|
||||
isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
placeholderBuilder: (_) => Icon(
|
||||
fallback,
|
||||
color: isActive ? Colors.white : const Color(0xFF6B7280),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgePreviewCircle extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final bool loading;
|
||||
final Color accentColor;
|
||||
final double size;
|
||||
|
||||
const _BadgePreviewCircle({
|
||||
required this.imageUrl,
|
||||
required this.loading,
|
||||
required this.accentColor,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFF2D3748),
|
||||
border: Border.all(color: const Color(0xFF4A5568), width: 4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accentColor.withOpacity(0.2),
|
||||
blurRadius: 40,
|
||||
spreadRadius: 8,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ClipOval(
|
||||
child: loading
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: accentColor.withOpacity(0.5),
|
||||
),
|
||||
)
|
||||
: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPlaceholder(),
|
||||
)
|
||||
: _buildPlaceholder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
color: const Color(0xFF1A202C),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined,
|
||||
size: 48, color: accentColor.withOpacity(0.4)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'暂无图片',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: accentColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassIconButton extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
const _GlassIconButton({required this.onTap, required this.child});
|
||||
@override
|
||||
State<_GlassIconButton> createState() => _GlassIconButtonState();
|
||||
}
|
||||
|
||||
class _GlassIconButtonState extends State<_GlassIconButton> {
|
||||
bool _pressed = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: (_) => setState(() => _pressed = true),
|
||||
onTapUp: (_) => setState(() => _pressed = false),
|
||||
onTapCancel: () => setState(() => _pressed = false),
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
widget.onTap();
|
||||
},
|
||||
child: AnimatedScale(
|
||||
scale: _pressed ? 0.92 : 1.0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassPillButton extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
const _GlassPillButton({required this.onTap, required this.child});
|
||||
@override
|
||||
State<_GlassPillButton> createState() => _GlassPillButtonState();
|
||||
}
|
||||
|
||||
class _GlassPillButtonState extends State<_GlassPillButton> {
|
||||
bool _pressed = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: (_) => setState(() => _pressed = true),
|
||||
onTapUp: (_) => setState(() => _pressed = false),
|
||||
onTapCancel: () => setState(() => _pressed = false),
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
widget.onTap();
|
||||
},
|
||||
child: AnimatedScale(
|
||||
scale: _pressed ? 0.94 : 1.0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
child: Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,15 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../pages/profile/profile_page.dart';
|
||||
import '../../../../theme/product_theme.dart';
|
||||
import '../../../../widgets/animated_gradient_background.dart';
|
||||
|
||||
/// 电子吧唧 AI (DZBJ-ON) 业务主页
|
||||
class BadgeControlPage extends ConsumerStatefulWidget {
|
||||
const BadgeControlPage({super.key});
|
||||
|
||||
@ -16,17 +19,18 @@ class BadgeControlPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _BadgeControlPageState extends ConsumerState<BadgeControlPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int _currentTab = 0; // 0: 主页, 1: 设置
|
||||
late AnimationController _floatController;
|
||||
late Animation<double> _floatAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi));
|
||||
_floatController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
)..repeat(reverse: true);
|
||||
// 自然呼吸曲线
|
||||
_floatAnimation = Tween<double>(begin: -8, end: 8).animate(
|
||||
CurvedAnimation(parent: _floatController, curve: Curves.easeInOut),
|
||||
);
|
||||
@ -47,98 +51,106 @@ class _BadgeControlPageState extends ConsumerState<BadgeControlPage>
|
||||
body: Stack(
|
||||
children: [
|
||||
const AnimatedGradientBackground(),
|
||||
SafeArea(
|
||||
child: Stack(
|
||||
|
||||
// 内容区
|
||||
IndexedStack(
|
||||
index: _currentTab,
|
||||
children: [
|
||||
_buildHomePage(productTheme),
|
||||
const ProfilePage(),
|
||||
],
|
||||
),
|
||||
|
||||
// 底部导航栏
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 12,
|
||||
child: _buildBottomNavBar(productTheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomePage(ProductThemeData productTheme) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 居中的吧唧图片
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _floatAnimation,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(0, _floatAnimation.value),
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: productTheme.accentColor.withOpacity(0.2),
|
||||
blurRadius: 60,
|
||||
spreadRadius: 15,
|
||||
),
|
||||
BoxShadow(
|
||||
color: productTheme.accentColorLight.withOpacity(0.1),
|
||||
blurRadius: 100,
|
||||
spreadRadius: 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/www/Capybara.png',
|
||||
width: 260,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.smart_toy,
|
||||
size: 150,
|
||||
color: productTheme.accentColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 顶部操作栏
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 居中的吧唧图片
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _floatAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _floatAnimation.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 主题色光晕
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: productTheme.accentColor
|
||||
.withOpacity(0.2),
|
||||
blurRadius: 60,
|
||||
spreadRadius: 15,
|
||||
),
|
||||
BoxShadow(
|
||||
color: productTheme.accentColorLight
|
||||
.withOpacity(0.1),
|
||||
blurRadius: 100,
|
||||
spreadRadius: 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/www/Capybara.png',
|
||||
width: 260,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.smart_toy,
|
||||
size: 150,
|
||||
color: productTheme.accentColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
_GlassIconButton(
|
||||
onTap: () => context.push('/product-selection'),
|
||||
child: SvgPicture.asset(
|
||||
'assets/www/icons/icon-switch.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF4B5563),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 顶部操作栏
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
_GlassPillButton(
|
||||
onTap: () => context.push('/badge'),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 切换产品按钮
|
||||
_GlassIconButton(
|
||||
onTap: () => context.push('/product-selection'),
|
||||
child: SvgPicture.asset(
|
||||
'assets/www/icons/icon-switch.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF4B5563),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 传图按钮
|
||||
_GlassPillButton(
|
||||
onTap: () => context.push('/badge'),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'传图',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: productTheme.accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.send_rounded,
|
||||
size: 16,
|
||||
color: productTheme.accentColor),
|
||||
],
|
||||
Text(
|
||||
'传图',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: productTheme.accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.send_rounded,
|
||||
size: 16, color: productTheme.accentColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -149,22 +161,95 @@ class _BadgeControlPageState extends ConsumerState<BadgeControlPage>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomNavBar(ProductThemeData productTheme) {
|
||||
return Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
width: 180,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.8)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF4B5563).withOpacity(0.08),
|
||||
offset: const Offset(0, 10),
|
||||
blurRadius: 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildNavItem(0, 'assets/www/icons/icon-home-pixel.svg',
|
||||
Icons.home, productTheme),
|
||||
_buildNavItem(1, 'assets/www/icons/icon-user-pixel.svg',
|
||||
Icons.person, productTheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
int index, String iconPath, IconData fallback, ProductThemeData theme) {
|
||||
final isActive = _currentTab == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _currentTab = index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive ? theme.buttonGradient : null,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.accentColor.withOpacity(0.4),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 15,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: SvgPicture.asset(
|
||||
iconPath,
|
||||
width: isActive ? 28 : 26,
|
||||
height: isActive ? 28 : 26,
|
||||
colorFilter: ColorFilter.mode(
|
||||
isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
placeholderBuilder: (_) => Icon(
|
||||
fallback,
|
||||
color: isActive ? Colors.white : const Color(0xFF6B7280),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 磨砂玻璃圆形图标按钮 — 带按压反馈
|
||||
class _GlassIconButton extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
|
||||
const _GlassIconButton({required this.onTap, required this.child});
|
||||
|
||||
@override
|
||||
State<_GlassIconButton> createState() => _GlassIconButtonState();
|
||||
}
|
||||
|
||||
class _GlassIconButtonState extends State<_GlassIconButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
@ -195,20 +280,16 @@ class _GlassIconButtonState extends State<_GlassIconButton> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 磨砂玻璃胶囊按钮 — 带按压反馈
|
||||
class _GlassPillButton extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
|
||||
const _GlassPillButton({required this.onTap, required this.child});
|
||||
|
||||
@override
|
||||
State<_GlassPillButton> createState() => _GlassPillButtonState();
|
||||
}
|
||||
|
||||
class _GlassPillButtonState extends State<_GlassPillButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
|
||||
@ -19,16 +19,17 @@ class DeviceController extends _$DeviceController {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> bindDevice(String sn, {int? spiritId}) async {
|
||||
/// 绑定设备,成功返回 null,失败返回错误信息
|
||||
Future<String?> bindDevice(String sn, {int? spiritId}) async {
|
||||
final repository = ref.read(deviceRepositoryProvider);
|
||||
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
||||
if (!ref.mounted) return false;
|
||||
if (!ref.mounted) return '组件已卸载';
|
||||
return result.fold(
|
||||
(failure) => false,
|
||||
(failure) => failure.message,
|
||||
(bindingId) {
|
||||
if (!ref.mounted) return false;
|
||||
if (!ref.mounted) return '组件已卸载';
|
||||
ref.invalidateSelf();
|
||||
return true;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ class MockDevice {
|
||||
final DeviceType type;
|
||||
final bool hasAI;
|
||||
final bool isNetworkRequired;
|
||||
final String bindStatus; // unbound / bound_by_me / bound_by_other
|
||||
final BluetoothDevice? bleDevice;
|
||||
|
||||
const MockDevice({
|
||||
@ -36,9 +37,12 @@ class MockDevice {
|
||||
required this.type,
|
||||
required this.hasAI,
|
||||
this.isNetworkRequired = true,
|
||||
this.bindStatus = 'unbound',
|
||||
this.bleDevice,
|
||||
});
|
||||
|
||||
bool get isBoundByOther => bindStatus == 'bound_by_other';
|
||||
|
||||
String get iconPath {
|
||||
switch (type) {
|
||||
case DeviceType.plush:
|
||||
@ -256,6 +260,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
final productCode = data['device_type']?['product_code'] as String? ?? '';
|
||||
final sn = data['sn'] as String? ?? '';
|
||||
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
|
||||
final bindStatus = data['bind_status'] as String? ?? 'unbound';
|
||||
final bleDevice = _pendingBleDevices[mac];
|
||||
|
||||
// API 返回了有效设备名 → 添加到列表
|
||||
@ -271,6 +276,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
type: _inferDeviceTypeByCode(productCode, displayName),
|
||||
hasAI: _inferHasAI(displayName),
|
||||
isNetworkRequired: isNetworkRequired,
|
||||
bindStatus: bindStatus,
|
||||
bleDevice: bleDevice,
|
||||
));
|
||||
}
|
||||
@ -387,7 +393,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
context.go('/badge-control');
|
||||
case DeviceType.badge:
|
||||
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic);
|
||||
context.go('/badge-control');
|
||||
context.go('/badge-basic-control');
|
||||
case DeviceType.plush:
|
||||
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
|
||||
context.go('/device-control');
|
||||
@ -405,14 +411,38 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
}
|
||||
|
||||
final device = _devices[_currentIndex];
|
||||
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}');
|
||||
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, bindStatus=${device.bindStatus}');
|
||||
|
||||
// 设备已被其他用户绑定 → 拦截
|
||||
if (device.isBoundByOther) {
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '无法连接',
|
||||
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
|
||||
confirmText: '确定',
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.isNetworkRequired) {
|
||||
// 不需要联网 -> 跳过配网,绑定设备后进入业务页
|
||||
if (device.sn.isNotEmpty) {
|
||||
setState(() => _isConnecting = true);
|
||||
try {
|
||||
await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
|
||||
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
|
||||
if (!mounted) return;
|
||||
if (error != null) {
|
||||
setState(() => _isConnecting = false);
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '绑定失败',
|
||||
description: error,
|
||||
confirmText: '确定',
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Bluetooth] bindDevice 异常: $e');
|
||||
}
|
||||
@ -428,7 +458,19 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
|
||||
setState(() => _isConnecting = true);
|
||||
if (device.sn.isNotEmpty) {
|
||||
await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
|
||||
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
|
||||
if (!mounted) return;
|
||||
if (error != null) {
|
||||
setState(() => _isConnecting = false);
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '绑定失败',
|
||||
description: error,
|
||||
confirmText: '确定',
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _isConnecting = false);
|
||||
@ -523,7 +565,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
children: [
|
||||
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/home'),
|
||||
onTap: () => context.pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
@ -788,9 +830,22 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
color: device.isBoundByOther
|
||||
? const Color(0xFF9CA3AF)
|
||||
: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
// 已被其他用户绑定提示
|
||||
if (device.isBoundByOther) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'已被其他用户绑定',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: const Color(0xFFEF4444),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -848,7 +903,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
||||
children: [
|
||||
// 取消按钮 - HTML: frosted glass with border
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/home'),
|
||||
onTap: () => context.pop(),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
child: Container(
|
||||
|
||||
@ -19,6 +19,7 @@ import '../widgets/ios_toast.dart';
|
||||
import '../widgets/animated_gradient_background.dart';
|
||||
import '../widgets/gradient_button.dart';
|
||||
import '../features/device/presentation/controllers/device_controller.dart';
|
||||
import '../theme/product_theme.dart';
|
||||
|
||||
class DeviceControlPage extends ConsumerStatefulWidget {
|
||||
const DeviceControlPage({super.key});
|
||||
@ -48,6 +49,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara));
|
||||
_mascotAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 4),
|
||||
|
||||
@ -205,14 +205,16 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
||||
if (boundDevice != null) {
|
||||
// 已绑定 → 根据产品类型进入对应控制页
|
||||
final pid = product['id'] as String;
|
||||
if (pid == 'badge-ai' || pid == 'badge-basic') {
|
||||
if (pid == 'badge-ai') {
|
||||
context.go('/badge-control');
|
||||
} else if (pid == 'badge-basic') {
|
||||
context.go('/badge-basic-control');
|
||||
} else {
|
||||
context.go('/device-control');
|
||||
}
|
||||
} else {
|
||||
// 未绑定 → 跳转蓝牙搜索页
|
||||
context.go('/bluetooth');
|
||||
// 未绑定 → 跳转蓝牙搜索页(push 保留返回栈)
|
||||
context.push('/bluetooth');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -8,6 +8,7 @@ import '../core/services/ble_provisioning_service.dart';
|
||||
import '../features/device/presentation/controllers/device_controller.dart';
|
||||
import '../widgets/animated_gradient_background.dart';
|
||||
import '../widgets/gradient_button.dart';
|
||||
import '../widgets/glass_dialog.dart';
|
||||
import '../theme/product_theme.dart';
|
||||
|
||||
class WifiConfigPage extends ConsumerStatefulWidget {
|
||||
@ -137,7 +138,19 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
if (sn.isNotEmpty) {
|
||||
try {
|
||||
debugPrint('[WiFi Config] Binding device sn=$sn');
|
||||
await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
|
||||
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
|
||||
if (!mounted) return;
|
||||
if (error != null) {
|
||||
setState(() => _isBinding = false);
|
||||
showGlassDialog(
|
||||
context: context,
|
||||
title: '绑定失败',
|
||||
description: error,
|
||||
confirmText: '确定',
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[WiFi Config] bindDevice 异常: $e');
|
||||
}
|
||||
@ -150,7 +163,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
||||
context.go('/badge-control');
|
||||
} else if (deviceType == 'badge') {
|
||||
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic);
|
||||
context.go('/badge-control');
|
||||
context.go('/badge-basic-control');
|
||||
} else {
|
||||
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
|
||||
context.go('/device-control');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user