Files
pyramid/lib/widgets/update_download_dialog.dart
T
Bernd Steckmeister 238fc3f670 fix: replace open_file_plus with native Kotlin MethodChannel for APK install
open_file_plus 3.4.1 uses deprecated PluginRegistry.Registrar API incompatible
with current Flutter. Replaced with a MethodChannel in MainActivity.kt that calls
FileProvider.getUriForFile() and launches the system package installer directly.

Also rework chat_input: 4px border radius on desktop, vPad 18px, 56px send button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:44:10 +02:00

386 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_logo.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>
with SingleTickerProviderStateMixin {
late final AnimationController _spinCtrl;
_DownloadState _state = _DownloadState.idle;
double _progress = 0.0;
int _received = 0;
int _total = 0;
String _errorMsg = '';
@override
void initState() {
super.initState();
_spinCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat();
_startDownload();
}
@override
void dispose() {
_spinCtrl.dispose();
super.dispose();
}
// ─── 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);
}
} 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: [
// ── Animated Pyramid ──
_AnimatedPyramid(ctrl: _spinCtrl, pt: pt),
const SizedBox(height: 24),
// ── Title ──
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)}';
}
}
// ─── Animated pyramid ─────────────────────────────────────────────────────────
class _AnimatedPyramid extends StatelessWidget {
final AnimationController ctrl;
final PyramidTheme pt;
const _AnimatedPyramid({required this.ctrl, required this.pt});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: ctrl,
builder: (context, _) {
final pulse = 0.9 + 0.1 * (0.5 - (ctrl.value - 0.5).abs()) * 2;
return Transform.scale(
scale: pulse,
child: PyramidLogo(size: 72, color: pt.accent),
);
},
);
}
}
// ─── 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,
),
),
),
),
);
}
}