Files
CloudHome-Ops/lib/screens/device/device_activation_page.dart

568 lines
16 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/store_info.dart';
import '../../services/device_service.dart';
import '../../services/org_service.dart';
import '../../services/api_client.dart';
import '../../utils/formatters.dart';
import '../../utils/i10n.dart';
/// 激活码管理页:门店选择、激活码信息、生成/重置。
class DeviceActivationPage extends ConsumerStatefulWidget {
const DeviceActivationPage({super.key});
@override
ConsumerState<DeviceActivationPage> createState() =>
_DeviceActivationPageState();
}
class _DeviceActivationPageState extends ConsumerState<DeviceActivationPage> {
final OrgService _orgService = OrgService();
final DeviceService _deviceService = DeviceService();
AppLocalizations get t => AppLocalizations.of(context);
List<StoreInfo> _stores = [];
String? _selectedStoreId;
bool _loadingStores = true;
Map<String, dynamic>? _codeInfo;
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadStores();
}
Future<void> _loadStores() async {
try {
final stores = await _orgService.getMyStores();
if (!mounted) return;
setState(() {
_stores = stores;
_loadingStores = false;
if (stores.isNotEmpty) {
_selectedStoreId = stores.first.orgId;
}
});
if (_selectedStoreId != null) {
_loadCodeInfo();
}
} catch (e) {
if (!mounted) return;
setState(() => _loadingStores = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).storeListLoadFailed(e.toString()))),
);
}
}
Future<void> _loadCodeInfo() async {
if (_selectedStoreId == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final info = await _deviceService.getActivationCodeInfo(
_selectedStoreId!,
);
if (!mounted) return;
setState(() {
_codeInfo = info;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_loading = false;
});
}
}
void _showGenerateSheet() {
if (_selectedStoreId == null) return;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => _GenerateCodeSheet(
deviceService: _deviceService,
orgId: _selectedStoreId!,
onSaved: () {
Navigator.pop(ctx);
_loadCodeInfo();
},
),
);
}
Future<void> _resetCode() async {
if (_selectedStoreId == null) return;
final t = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.resetActivationCode),
content: Text(t.resetActivationCodeConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.orange),
child: Text(t.reset),
),
],
),
);
if (confirmed != true) return;
try {
await _deviceService.resetActivationCode(_selectedStoreId!);
if (!mounted) return;
// 结果以页面中央弹窗展示(替代左下方 SnackBar)。
_showResultDialog(
isSuccess: true,
title: t.activationCodeResetSuccess,
);
_loadCodeInfo();
} catch (e) {
if (!mounted) return;
_showResultDialog(
isSuccess: false,
title: t.activationCodeResetFailed(formatApiError(e)),
);
}
}
/// 居中弹窗展示激活码重置结果(成功/失败)。
///
/// 替代原左下角 SnackBar,保证用户清晰看到操作反馈。
void _showResultDialog({
required bool isSuccess,
required String title,
}) {
final t = AppLocalizations.of(context);
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
color: isSuccess ? Colors.green : Colors.red,
size: 42,
),
title: Center(child: Text(title, textAlign: TextAlign.center)),
actionsAlignment: MainAxisAlignment.center,
actions: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.confirm),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.activationCodeManagement),
),
body: Column(
children: [
_buildStoreSelector(),
const Divider(height: 1),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildStoreSelector() {
if (_loadingStores) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (_stores.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(t.noStoreAvailable),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: DropdownButtonFormField<String>(
value: _selectedStoreId,
decoration: InputDecoration(
labelText: t.selectStore,
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
),
items: _stores
.map((s) => DropdownMenuItem(
value: s.orgId,
child: Text(s.name),
))
.toList(),
onChanged: (v) {
setState(() => _selectedStoreId = v);
_loadCodeInfo();
},
),
);
}
Widget _buildBody() {
if (_selectedStoreId == null) {
return Center(child: Text(t.pleaseSelectStoreFirst));
}
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(t.activationCodeLoadFailed(_error!)),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadCodeInfo,
child: Text(t.retry),
),
],
),
);
}
if (_codeInfo == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.vpn_key_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(t.noActivationCode),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _showGenerateSheet,
icon: const Icon(Icons.add),
label: Text(t.generateActivationCode),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadCodeInfo,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
_buildCodeCard(),
const SizedBox(height: 16),
_buildInfoCard(),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _showGenerateSheet,
icon: const Icon(Icons.refresh),
label: Text(t.generateActivationCode),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _resetCode,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange,
),
icon: const Icon(Icons.restart_alt),
label: Text(t.resetActivationCode),
),
),
],
),
],
),
);
}
Widget _buildCodeCard() {
final code = _codeInfo!['code']?.toString() ?? '';
final theme = Theme.of(context);
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.vpn_key,
color: theme.colorScheme.primary, size: 20),
const SizedBox(width: 8),
Text(t.currentActivationCode,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 16),
if (code.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
t.activationCodeNoCodeHint,
style: const TextStyle(color: Colors.grey),
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.4)),
),
child: SelectableText(
code,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: theme.colorScheme.onPrimaryContainer,
),
),
),
if (code.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: code));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.activationCodeCopied)),
);
},
icon: const Icon(Icons.copy, size: 18),
label: Text(t.copy),
),
],
),
],
],
),
),
);
}
Widget _buildInfoCard() {
final theme = Theme.of(context);
final maxCount = _codeInfo!['maxActivateCount'];
final activatedCount = _codeInfo!['activatedCount'];
final createTime = _codeInfo!['createTime'];
String _formatTime(dynamic t) {
if (t == null) return '—';
final text = t.toString().trim();
if (text.isEmpty) return '—';
final dt = DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
return dt != null ? Formatters.formatDateTime(dt) : text;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_infoRow(
theme,
t.maxActivateCount,
maxCount?.toString() ?? '—',
),
_infoRow(
theme,
t.activatedCount,
activatedCount?.toString() ?? '—',
),
_infoRow(theme, t.createTime, _formatTime(createTime)),
],
),
),
);
}
Widget _infoRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(
label,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Text(value,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
),
],
),
);
}
}
/// 生成激活码表单。
class _GenerateCodeSheet extends StatefulWidget {
const _GenerateCodeSheet({
required this.deviceService,
required this.orgId,
required this.onSaved,
});
final DeviceService deviceService;
final String orgId;
final VoidCallback onSaved;
@override
State<_GenerateCodeSheet> createState() => _GenerateCodeSheetState();
}
class _GenerateCodeSheetState extends State<_GenerateCodeSheet> {
final _formKey = GlobalKey<FormState>();
final _countController = TextEditingController(text: '1');
bool _saving = false;
@override
void dispose() {
_countController.dispose();
super.dispose();
}
Future<void> _save() async {
final t = AppLocalizations.of(context);
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final count = int.parse(_countController.text.trim());
await widget.deviceService.generateActivationCode(
widget.orgId,
count,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.activationCodeGenerated)),
);
widget.onSaved();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.generateFailedWithError(e.toString()))),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
t.generateActivationCode,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
t.generateActivationCodeHint,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 20),
TextFormField(
controller: _countController,
decoration: InputDecoration(
labelText: t.maxActivationCountLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.looks),
helperText: t.maxActivationCountHint,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (v) {
final count = int.tryParse(v ?? '');
if (count == null || count <= 0) {
return t.positiveIntegerRequired;
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.generate),
),
),
],
),
),
),
);
}
}