feat: in-app update downloader with progress, Gitea auto-push hook
- update_checker: Gitea at git.steggi-matrix.work, Windows + Android support, asset URL detection - update_download_dialog: animated Pyramid logo, progress bar, platform install (exe/apk) - update_banner: Herunterladen button triggers dialog, dismiss option - Android: REQUEST_INSTALL_PACKAGES permission + FileProvider for APK install - post-commit hook: auto-push to Gitea on every commit - chat_input: larger send button (60px desktop), more padding, bigger icon - rooms_panel: space menu visible on mobile (leave space fix) - docs: feature-checklist.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
const _kGiteaBase = 'https://git.steggi-matrix.work';
|
||||
const _kGiteaRepo = 'steggi/pyramid';
|
||||
const _kCheckIntervalHours = 4;
|
||||
const _kPrefLastCheck = 'update_last_check_ms';
|
||||
const _kPrefSkippedTag = 'update_skipped_tag';
|
||||
|
||||
// ─── Data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
class UpdateInfo {
|
||||
final String tagName;
|
||||
final String releaseName;
|
||||
final String body;
|
||||
final String htmlUrl;
|
||||
final String? downloadUrl;
|
||||
final (int, int, int) remoteVersion;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.tagName,
|
||||
required this.releaseName,
|
||||
required this.body,
|
||||
required this.htmlUrl,
|
||||
required this.remoteVersion,
|
||||
this.downloadUrl,
|
||||
});
|
||||
|
||||
bool get canDownload => downloadUrl != null;
|
||||
}
|
||||
|
||||
// ─── Version helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
(int, int, int)? _parseVersion(String raw) {
|
||||
final clean = raw.trim().replaceFirst(RegExp(r'^v'), '').split('+').first;
|
||||
final parts = clean.split('.');
|
||||
if (parts.length < 3) return null;
|
||||
try {
|
||||
return (int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2]));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isNewer((int, int, int) remote, (int, int, int) local) {
|
||||
if (remote.$1 != local.$1) return remote.$1 > local.$1;
|
||||
if (remote.$2 != local.$2) return remote.$2 > local.$2;
|
||||
return remote.$3 > local.$3;
|
||||
}
|
||||
|
||||
String? _findDownloadUrl(List<dynamic> assets) {
|
||||
if (assets.isEmpty) return null;
|
||||
final list = assets.cast<Map<String, dynamic>>();
|
||||
|
||||
String? pick(bool Function(String name) test) {
|
||||
for (final a in list) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase();
|
||||
if (test(name)) return a['browser_download_url'] as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
return pick((n) => n.endsWith('.exe')) ?? pick((n) => n.endsWith('.msix'));
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return pick((n) => n.endsWith('.apk'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Fetch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<UpdateInfo?> _fetchLatestRelease() async {
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=1',
|
||||
);
|
||||
final response = await http
|
||||
.get(uri, headers: {'Accept': 'application/json'})
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final list = json.decode(response.body) as List<dynamic>;
|
||||
if (list.isEmpty) return null;
|
||||
final data = list.first as Map<String, dynamic>;
|
||||
|
||||
final tagName = data['tag_name'] as String? ?? '';
|
||||
if (tagName.isEmpty) return null;
|
||||
|
||||
final remoteVer = _parseVersion(tagName);
|
||||
if (remoteVer == null) return null;
|
||||
|
||||
final assets = (data['assets'] as List<dynamic>?) ?? [];
|
||||
final downloadUrl = _findDownloadUrl(assets);
|
||||
|
||||
return UpdateInfo(
|
||||
tagName: tagName,
|
||||
releaseName: data['name'] as String? ?? tagName,
|
||||
body: data['body'] as String? ?? '',
|
||||
htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName',
|
||||
remoteVersion: remoteVer,
|
||||
downloadUrl: downloadUrl,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||
// iOS: can't sideload, skip
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) return null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastCheckMs = prefs.getInt(_kPrefLastCheck) ?? 0;
|
||||
final elapsed = DateTime.now().millisecondsSinceEpoch - lastCheckMs;
|
||||
if (elapsed < _kCheckIntervalHours * 3600 * 1000) return null;
|
||||
|
||||
await prefs.setInt(_kPrefLastCheck, DateTime.now().millisecondsSinceEpoch);
|
||||
|
||||
final remote = await _fetchLatestRelease();
|
||||
if (remote == null) return null;
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVer = _parseVersion(info.version);
|
||||
if (localVer == null) return null;
|
||||
|
||||
if (!_isNewer(remote.remoteVersion, localVer)) return null;
|
||||
|
||||
final skipped = prefs.getString(_kPrefSkippedTag);
|
||||
if (skipped == remote.tagName) return null;
|
||||
|
||||
return remote;
|
||||
});
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> skipUpdateVersion(String tagName) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kPrefSkippedTag, tagName);
|
||||
}
|
||||
|
||||
Future<void> resetUpdateCheckTimer() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kPrefLastCheck);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
@@ -53,6 +54,10 @@ class _ChatInputState extends State<ChatInput> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDesktop = !kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS);
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
@@ -63,7 +68,12 @@ class _ChatInputState extends State<ChatInput> {
|
||||
onClear: widget.onClearReply ?? () {},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isDesktop ? 12 : 8,
|
||||
isDesktop ? 10 : 4,
|
||||
isDesktop ? 12 : 8,
|
||||
isDesktop ? 14 : 8,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
@@ -73,14 +83,19 @@ class _ChatInputState extends State<ChatInput> {
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
style: TextStyle(fontSize: isDesktop ? 16 : 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Nachricht...',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: isDesktop ? 16 : 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(100),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? 22 : 16,
|
||||
vertical: isDesktop ? 20 : 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(isDesktop ? 16 : 24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
@@ -89,19 +104,30 @@ class _ChatInputState extends State<ChatInput> {
|
||||
onSubmitted: (_) => _send(),
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [
|
||||
// Shift+Enter = newline, Enter = send
|
||||
_SendOnEnterFormatter(onSend: _send),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedScale(
|
||||
scale: _canSend ? 1.0 : 0.8,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: _canSend ? _send : null,
|
||||
elevation: _canSend ? 2 : 0,
|
||||
child: const Icon(Icons.send_rounded),
|
||||
SizedBox(width: isDesktop ? 10 : 8),
|
||||
GestureDetector(
|
||||
onTap: _canSend ? _send : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: isDesktop ? 60 : 44,
|
||||
height: isDesktop ? 60 : 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _canSend
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primary.withAlpha(70),
|
||||
borderRadius: BorderRadius.circular(isDesktop ? 16 : 14),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.send_rounded,
|
||||
size: isDesktop ? 26 : 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/update_checker.dart';
|
||||
import 'package:pyramid/widgets/update_download_dialog.dart';
|
||||
|
||||
/// Slim top banner shown when a new Pyramid version is available.
|
||||
class UpdateBanner extends ConsumerWidget {
|
||||
const UpdateBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ref.watch(updateInfoProvider).when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (e, _) => const SizedBox.shrink(),
|
||||
data: (info) =>
|
||||
info == null ? const SizedBox.shrink() : _Banner(info: info),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Banner extends ConsumerWidget {
|
||||
final UpdateInfo info;
|
||||
const _Banner({required this.info});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final canDownload = info.canDownload;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent.withAlpha(28),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: pt.accent.withAlpha(80)),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.system_update_alt_rounded, size: 15, color: pt.accent),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pyramid ${info.tagName} ist verfügbar',
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 12.5, fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_BannerBtn(
|
||||
label: 'Was ist neu',
|
||||
icon: Icons.open_in_new_rounded,
|
||||
color: pt.bg3,
|
||||
fgColor: pt.fg,
|
||||
onTap: () => _openUrl(info.htmlUrl),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_BannerBtn(
|
||||
label: canDownload ? 'Herunterladen' : 'Öffnen',
|
||||
icon: canDownload
|
||||
? Icons.download_rounded
|
||||
: Icons.open_in_new_rounded,
|
||||
color: pt.accent,
|
||||
fgColor: pt.accentFg,
|
||||
onTap: () {
|
||||
if (canDownload) {
|
||||
showUpdateDownloadDialog(context, info);
|
||||
} else {
|
||||
_openUrl(info.htmlUrl);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: 'Für diese Version ausblenden',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: () async {
|
||||
await skipUpdateVersion(info.tagName);
|
||||
ref.invalidate(updateInfoProvider);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(Icons.close_rounded, size: 14, color: pt.fgDim),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BannerBtn extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color fgColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _BannerBtn({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.fgColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration:
|
||||
BoxDecoration(color: color, borderRadius: BorderRadius.circular(6)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: fgColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
color: fgColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:open_file_plus/open_file_plus.dart';
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _install(File file) async {
|
||||
setState(() => _state = _DownloadState.installing);
|
||||
|
||||
if (Platform.isWindows) {
|
||||
await Process.start(file.path, [], runInShell: false);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
exit(0);
|
||||
} else if (Platform.isAndroid) {
|
||||
final result = await OpenFile.open(
|
||||
file.path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
if (mounted) {
|
||||
if (result.type == ResultType.done) {
|
||||
setState(() => _state = _DownloadState.done);
|
||||
} else {
|
||||
setState(() {
|
||||
_state = _DownloadState.error;
|
||||
_errorMsg = result.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user