25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
370 lines
12 KiB
Dart
370 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
import 'package:pyramid/core/update_checker.dart';
|
|
import 'package:pyramid/widgets/pyramid_loader.dart';
|
|
|
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
|
|
Future<void> showUpdateDownloadDialog(BuildContext context, UpdateInfo info) {
|
|
return showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
barrierColor: Colors.black.withAlpha(160),
|
|
builder: (_) => _UpdateDownloadDialog(info: info),
|
|
);
|
|
}
|
|
|
|
// ─── States ───────────────────────────────────────────────────────────────────
|
|
|
|
enum _DownloadState { idle, downloading, installing, done, error }
|
|
|
|
// ─── Dialog widget ────────────────────────────────────────────────────────────
|
|
|
|
class _UpdateDownloadDialog extends ConsumerStatefulWidget {
|
|
final UpdateInfo info;
|
|
const _UpdateDownloadDialog({required this.info});
|
|
|
|
@override
|
|
ConsumerState<_UpdateDownloadDialog> createState() =>
|
|
_UpdateDownloadDialogState();
|
|
}
|
|
|
|
class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> {
|
|
_DownloadState _state = _DownloadState.idle;
|
|
double _progress = 0.0;
|
|
int _received = 0;
|
|
int _total = 0;
|
|
String _errorMsg = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_startDownload();
|
|
}
|
|
|
|
// ─── Download ───────────────────────────────────────────────────────────────
|
|
|
|
Future<void> _startDownload() async {
|
|
setState(() {
|
|
_state = _DownloadState.downloading;
|
|
_progress = 0;
|
|
_received = 0;
|
|
_total = 0;
|
|
});
|
|
|
|
try {
|
|
final dir = await getTemporaryDirectory();
|
|
final downloadDir = Directory(p.join(dir.path, 'downloads'));
|
|
await downloadDir.create(recursive: true);
|
|
|
|
final ext = Platform.isWindows ? '.exe' : '.apk';
|
|
final fileName = 'pyramid-update-${widget.info.tagName}$ext';
|
|
final file = File(p.join(downloadDir.path, fileName));
|
|
final request = http.Request('GET', Uri.parse(widget.info.downloadUrl!));
|
|
final streamedResponse =
|
|
await http.Client().send(request).timeout(const Duration(minutes: 5));
|
|
|
|
_total = streamedResponse.contentLength ?? 0;
|
|
final sink = file.openWrite();
|
|
|
|
await for (final chunk in streamedResponse.stream) {
|
|
sink.add(chunk);
|
|
_received += chunk.length;
|
|
if (mounted) {
|
|
setState(() {
|
|
_progress = _total > 0 ? _received / _total : 0;
|
|
});
|
|
}
|
|
}
|
|
await sink.flush();
|
|
await sink.close();
|
|
|
|
if (!mounted) return;
|
|
await _install(file);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_state = _DownloadState.error;
|
|
_errorMsg = e.toString().split('\n').first;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
static const _installChannel =
|
|
MethodChannel('chat.pyramid.pyramid/install');
|
|
|
|
Future<void> _install(File file) async {
|
|
setState(() => _state = _DownloadState.installing);
|
|
|
|
try {
|
|
if (Platform.isWindows) {
|
|
await Process.start(file.path, [], runInShell: false);
|
|
if (mounted) Navigator.of(context).pop();
|
|
exit(0);
|
|
} else if (Platform.isAndroid) {
|
|
await _installChannel
|
|
.invokeMethod('installApk', {'path': file.path});
|
|
if (mounted) setState(() => _state = _DownloadState.done);
|
|
}
|
|
} on PlatformException catch (e) {
|
|
if (e.code == 'PERMISSION_REQUIRED') {
|
|
if (mounted) {
|
|
setState(() {
|
|
_state = _DownloadState.error;
|
|
_errorMsg = 'Bitte "Unbekannte Apps installieren" für Pyramid erlauben (Einstellungen geöffnet) — dann erneut versuchen.';
|
|
});
|
|
}
|
|
} else {
|
|
if (mounted) {
|
|
setState(() {
|
|
_state = _DownloadState.error;
|
|
_errorMsg = e.message ?? e.toString();
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_state = _DownloadState.error;
|
|
_errorMsg = e.toString().split('\n').first;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
String _formatBytes(int bytes) {
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
// ─── UI ─────────────────────────────────────────────────────────────────────
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = PyramidTheme.of(context);
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
child: Container(
|
|
width: 320,
|
|
padding: const EdgeInsets.fromLTRB(32, 36, 32, 28),
|
|
decoration: BoxDecoration(
|
|
color: pt.bg1,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: pt.border),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withAlpha(120),
|
|
blurRadius: 40,
|
|
spreadRadius: 4,
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ── Loading animation ──
|
|
const PyramidLoader(size: 72),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Title (hidden while actively downloading) ──
|
|
if (_state != _DownloadState.downloading &&
|
|
_state != _DownloadState.installing) ...[
|
|
Text(
|
|
_titleText(),
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
Text(
|
|
widget.info.tagName,
|
|
style: TextStyle(color: pt.accent, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Progress / Status ──
|
|
if (_state == _DownloadState.downloading) ...[
|
|
_ProgressBar(progress: _progress, pt: pt),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
_progressLabel(),
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
|
),
|
|
] else if (_state == _DownloadState.installing) ...[
|
|
_ProgressBar(progress: 1.0, pt: pt),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'Wird installiert…',
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
|
),
|
|
] else if (_state == _DownloadState.done) ...[
|
|
Icon(Icons.check_circle_rounded, color: pt.accent, size: 32),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Update installiert',
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
|
),
|
|
] else if (_state == _DownloadState.error) ...[
|
|
Icon(Icons.error_outline_rounded,
|
|
color: pt.danger, size: 32),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_errorMsg,
|
|
style: TextStyle(color: pt.danger, fontSize: 11),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// ── Action buttons ──
|
|
if (_state == _DownloadState.error)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _DialogBtn(
|
|
label: 'Abbrechen',
|
|
pt: pt,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _DialogBtn(
|
|
label: 'Erneut',
|
|
pt: pt,
|
|
primary: true,
|
|
onTap: _startDownload,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else if (_state == _DownloadState.done)
|
|
_DialogBtn(
|
|
label: 'Schließen',
|
|
pt: pt,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
)
|
|
else if (_state == _DownloadState.downloading)
|
|
_DialogBtn(
|
|
label: 'Abbrechen',
|
|
pt: pt,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _titleText() => switch (_state) {
|
|
_DownloadState.idle => 'Vorbereitung…',
|
|
_DownloadState.downloading => 'Update wird geladen',
|
|
_DownloadState.installing => 'Wird installiert',
|
|
_DownloadState.done => 'Fertig!',
|
|
_DownloadState.error => 'Fehler beim Download',
|
|
};
|
|
|
|
String _progressLabel() {
|
|
final pct = (_progress * 100).toStringAsFixed(0);
|
|
if (_total > 0) {
|
|
return '$pct% · ${_formatBytes(_received)} / ${_formatBytes(_total)}';
|
|
}
|
|
return '$pct% · ${_formatBytes(_received)}';
|
|
}
|
|
}
|
|
|
|
// ─── Progress bar ─────────────────────────────────────────────────────────────
|
|
|
|
class _ProgressBar extends StatelessWidget {
|
|
final double progress;
|
|
final PyramidTheme pt;
|
|
const _ProgressBar({required this.progress, required this.pt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: pt.bg3,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: FractionallySizedBox(
|
|
alignment: Alignment.centerLeft,
|
|
widthFactor: progress.clamp(0.0, 1.0),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
decoration: BoxDecoration(
|
|
color: pt.accent,
|
|
borderRadius: BorderRadius.circular(3),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: pt.accent.withAlpha(100),
|
|
blurRadius: 6,
|
|
spreadRadius: 1,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Dialog button ────────────────────────────────────────────────────────────
|
|
|
|
class _DialogBtn extends StatelessWidget {
|
|
final String label;
|
|
final PyramidTheme pt;
|
|
final bool primary;
|
|
final VoidCallback onTap;
|
|
|
|
const _DialogBtn({
|
|
required this.label,
|
|
required this.pt,
|
|
required this.onTap,
|
|
this.primary = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: primary ? pt.accent : pt.bg3,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: primary ? pt.accentFg : pt.fgMuted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|