import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../core/app_controller.dart'; import '../core/failure.dart'; import '../l10n/strings.dart'; import '../models/server_profile.dart'; import '../services/local_files.dart'; import '../services/profile_transfer.dart'; import 'adaptive.dart'; /// Imports desktop-compatible `chj-server-profiles` version 1 JSON after a /// preview and a duplicate policy. Credentials are never part of the file. Future importProfiles(BuildContext context, AppController c) async { final epoch = c.vault.generation; final file = await c.withSystemDialog( () => FilePicker.pickFile(type: FileType.custom, allowedExtensions: ['json']), ); if (file == null || !context.mounted || epoch != c.vault.generation || !c.vault.unlocked) { return; } final profiles = ProfileTransfer.decode( await readBounded(file.readAsByteStream(), ProfileTransfer.maxBytes), ); if (!context.mounted) return; final policy = await Navigator.push( context, MaterialPageRoute( builder: (_) => ImportPreview(profiles, c.vault.profiles), ), ); if (policy == null || !c.vault.unlocked || epoch != c.vault.generation) { return; } await c.vault.change((d) { final list = d['profiles'] as List; final seen = { for (final p in list) ServerProfile.fromJson(Map.from(p)).identity: p, }; for (final p in profiles) { final match = seen[p.identity]; if (match != null && policy == 'skipDuplicates') continue; final next = ServerProfile( id: match != null && policy == 'updateDuplicates' ? match['id'] : null, name: p.name, host: p.host, username: p.username, port: p.port, ); if (match != null && policy == 'updateDuplicates') { list[list.indexOf(match)] = next.toJson(); (d['secrets'] as Map).remove(next.id); } else { list.add(next.toJson()); } seen[p.identity] = list.lastWhere((r) => r['id'] == next.id); } }); c.ssh.disconnect(clear: true); c.changed(); } /// Exports profiles as desktop-compatible JSON, without credentials. Future exportProfiles(AppController c) async { if (c.vault.profiles.isEmpty) throw const Failure('invalidImport'); final bytes = Uint8List.fromList(ProfileTransfer.encode(c.vault.profiles)); await c.withSystemDialog( () => FilePicker.saveFile( fileName: 'chj-server-profiles.json', bytes: bytes, mimeType: 'application/json', ), ); } class ImportPreview extends StatefulWidget { final List profiles, existing; const ImportPreview(this.profiles, this.existing, {super.key}); @override State createState() => _ImportPreviewState(); } class _ImportPreviewState extends State { String policy = 'skipDuplicates'; @override Widget build(BuildContext context) { final seen = widget.existing.map((p) => p.identity).toSet(); final duplicates = widget.profiles.map((p) { final found = !seen.add(p.identity); return found; }).toList(); return Scaffold( appBar: AppBar(title: Text(context.tr('importPreview'))), body: SafeArea( child: MinHeight( minHeight: 320, child: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Text(context.tr('importAdvice')), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: DropdownButtonFormField( initialValue: policy, isExpanded: true, items: ['skipDuplicates', 'updateDuplicates', 'createDuplicates'] .map( (v) => DropdownMenuItem( value: v, child: Text( context.tr(v), overflow: TextOverflow.ellipsis, ), ), ) .toList(), onChanged: (v) => setState(() => policy = v!), ), ), Expanded( child: ListView.builder( itemCount: widget.profiles.length, itemBuilder: (ctx, i) { final p = widget.profiles[i]; return ListTile( title: Text(p.name), subtitle: Text('${p.username}@${p.host}:${p.port}'), trailing: duplicates[i] ? Tooltip( message: ctx.tr('duplicate'), child: const Icon(Icons.copy), ) : null, ); }, ), ), Padding( padding: const EdgeInsets.all(16), child: FilledButton( onPressed: () => Navigator.pop(context, policy), child: Text(context.tr('importAction')), ), ), ], ), ), ), ); } }