mobile_nebula/lib/screens/SiteDetailScreen.dart
Ian VanSchooten 991837676a
Add DangerButton component (#219)
This pulls out a component that we can use for "dangerous" operations like deleting, and styles it in one place.

It also starts to move us slowly towards Material 3, with the rounded corners on these buttons in Android.  

Android:

|Before Light|Before Dark|After Light|After Dark|
|---|---|---|---|
|<img width="425" alt="Android Studio 2025-01-15 14 16 36" src="https://github.com/user-attachments/assets/4823e551-6a40-48dd-9bc1-3004699b90ea" />|<img width="417" alt="Android Studio 2025-01-15 14 16 47" src="https://github.com/user-attachments/assets/df5461fd-586e-47bb-99b9-0212e63f0454" />|<img width="413" alt="Android Studio 2025-01-15 14 15 59" src="https://github.com/user-attachments/assets/d88a6225-b71a-4886-8387-e35811a3a6ec" />|<img width="418" alt="Android Studio 2025-01-15 14 16 15" src="https://github.com/user-attachments/assets/d4f23b1c-7003-4a00-b865-4a123d8fe3e9" />|


iOS:

|Before Light|Before Dark|After Light|After Dark|
|---|---|---|---|
|<img width="437" alt="Simulator 2025-01-15 15 56 26" src="https://github.com/user-attachments/assets/87c4eed3-6d07-4858-8ad8-d8c011538154" />|<img width="445" alt="Simulator 2025-01-15 15 56 36" src="https://github.com/user-attachments/assets/9dc5b174-7bc7-48ec-a3c0-61633168c31a" />|<img width="439" alt="Simulator 2025-01-15 16 05 23" src="https://github.com/user-attachments/assets/31dc9ab6-8a3c-49c7-892d-627f16e2a8cd" />|<img width="444" alt="Simulator 2025-01-15 16 05 37" src="https://github.com/user-attachments/assets/979280d6-e1f4-4d57-a86a-10bb4def729a" />|
2025-01-16 08:16:23 -05:00

299 lines
9.3 KiB
Dart

import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mobile_nebula/components/SimplePage.dart';
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart';
import 'package:mobile_nebula/components/config/ConfigSection.dart';
import 'package:mobile_nebula/models/HostInfo.dart';
import 'package:mobile_nebula/models/Site.dart';
import 'package:mobile_nebula/screens/SiteLogsScreen.dart';
import 'package:mobile_nebula/screens/SiteTunnelsScreen.dart';
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../components/DangerButton.dart';
//TODO: If the site isn't active, don't respond to reloads on hostmaps
//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
class SiteDetailScreen extends StatefulWidget {
const SiteDetailScreen({
Key? key,
required this.site,
this.onChanged,
required this.supportsQRScanning,
}) : super(key: key);
final Site site;
final Function? onChanged;
final bool supportsQRScanning;
@override
_SiteDetailScreenState createState() => _SiteDetailScreenState();
}
class _SiteDetailScreenState extends State<SiteDetailScreen> {
late Site site;
late StreamSubscription onChange;
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
bool changed = false;
List<HostInfo>? activeHosts;
List<HostInfo>? pendingHosts;
RefreshController refreshController = RefreshController(initialRefresh: false);
@override
void initState() {
site = widget.site;
if (site.connected) {
_listHostmap();
}
onChange = site.onChange().listen((_) {
// TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
// If we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
if (site.status == 'Connected') {
_listHostmap();
} else {
activeHosts = null;
pendingHosts = null;
}
setState(() {});
}, onError: (err) {
setState(() {});
Utils.popError(context, "Error", err);
});
super.initState();
}
@override
void dispose() {
onChange.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dnIcon =
Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
final title = Row(children: [
site.managed
? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12))
: Container(),
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)))
]);
return SimplePage(
title: title,
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
if (changed && widget.onChanged != null) {
widget.onChanged!();
}
Navigator.pop(context);
}),
refreshController: refreshController,
onRefresh: () async {
if (site.connected && site.status == "Connected") {
await _listHostmap();
}
refreshController.refreshCompleted();
},
child: Column(children: [
_buildErrors(),
_buildConfig(),
site.connected ? _buildHosts() : Container(),
_buildSiteDetails(),
_buildDelete(),
]));
}
Widget _buildErrors() {
if (site.errors.length == 0) {
return Container();
}
List<Widget> items = [];
site.errors.forEach((error) {
items.add(ConfigItem(
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error))));
});
return ConfigSection(
label: 'ERRORS',
borderColor: CupertinoColors.systemRed.resolveFrom(context),
labelColor: CupertinoColors.systemRed.resolveFrom(context),
children: items,
);
}
Widget _buildConfig() {
return ConfigSection(children: <Widget>[
ConfigItem(
label: Text('Status'),
content: Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 5),
child: Text(widget.site.status,
style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)))),
Switch.adaptive(
value: widget.site.connected,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (v) async {
try {
if (v) {
await widget.site.start();
} else {
await widget.site.stop();
}
} catch (error) {
var action = v ? 'start' : 'stop';
Utils.popError(context, 'Failed to $action the site', error.toString());
}
},
)
])),
ConfigPageItem(
label: Text('Logs'),
onPressed: () {
Utils.openPage(context, (context) {
return SiteLogsScreen(site: widget.site);
});
},
),
]);
}
Widget _buildHosts() {
Widget active, pending;
if (activeHosts == null) {
active = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
} else {
active = Text(Utils.itemCountFormat(activeHosts!.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
}
if (pendingHosts == null) {
pending = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
} else {
pending = Text(Utils.itemCountFormat(pendingHosts!.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
}
return ConfigSection(
label: "TUNNELS",
children: <Widget>[
ConfigPageItem(
onPressed: () {
if (activeHosts == null) return;
Utils.openPage(
context,
(context) => SiteTunnelsScreen(
pending: false,
tunnels: activeHosts!,
site: site,
onChanged: (hosts) {
setState(() {
activeHosts = hosts;
});
},
supportsQRScanning: widget.supportsQRScanning,
));
},
label: Text("Active"),
content: Container(alignment: Alignment.centerRight, child: active)),
ConfigPageItem(
onPressed: () {
if (pendingHosts == null) return;
Utils.openPage(
context,
(context) => SiteTunnelsScreen(
pending: true,
tunnels: pendingHosts!,
site: site,
onChanged: (hosts) {
setState(() {
pendingHosts = hosts;
});
},
supportsQRScanning: widget.supportsQRScanning,
));
},
label: Text("Pending"),
content: Container(alignment: Alignment.centerRight, child: pending))
],
);
}
Widget _buildSiteDetails() {
return ConfigSection(children: <Widget>[
ConfigPageItem(
crossAxisAlignment: CrossAxisAlignment.center,
content: Text('Configuration'),
onPressed: () {
Utils.openPage(context, (context) {
return SiteConfigScreen(
site: widget.site,
onSave: (site) async {
changed = true;
setState(() {});
},
supportsQRScanning: widget.supportsQRScanning,
);
});
},
),
]);
}
Widget _buildDelete() {
return Padding(
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: DangerButton(
child: Text('Delete'),
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async {
if (await _deleteSite()) {
Navigator.of(context).pop();
}
}),
)));
}
_listHostmap() async {
try {
var maps = await site.listAllHostmaps();
activeHosts = maps["active"];
pendingHosts = maps["pending"];
setState(() {});
} catch (err) {
Utils.popError(context, 'Error while fetching hostmaps', err.toString());
}
}
Future<bool> _deleteSite() async {
try {
var err = await platform.invokeMethod("deleteSite", widget.site.id);
if (err != null) {
Utils.popError(context, 'Failed to delete the site', err);
return false;
}
} catch (err) {
Utils.popError(context, 'Failed to delete the site', err.toString());
return false;
}
if (widget.onChanged != null) {
widget.onChanged!();
}
return true;
}
}