Skip to content

Commit

Permalink
Merge pull request #3504 from canonical/desktop-gui
Browse files Browse the repository at this point in the history
GUI Improvements
  • Loading branch information
ricab committed Jul 6, 2024
1 parent 6c997b5 commit afd2d19
Show file tree
Hide file tree
Showing 53 changed files with 1,842 additions and 791 deletions.
5 changes: 0 additions & 5 deletions data/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
install(
FILES multipass.gui.autostart.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/multipass
)

install(
FILES multipass.gui.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications
Expand Down
12 changes: 0 additions & 12 deletions snap-wrappers/bin/client-common.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
#!/bin/sh

export SNAP_REAL_HOME=$( getent passwd $USER | cut -d: -f6 )

# so that snapd finds autostart desktop files where it expects them (see https://snapcraft.io/docs/snap-format)
link_autostart()
{
if [ ! -z "$SNAP_USER_DATA" ] # ensure we're in a proper environment
then
mkdir -p $SNAP_USER_DATA/.config
ln -sfnt $SNAP_USER_DATA/.config ../config/autostart
else
echo "WARNING: SNAP_USER_DATA empty"
fi
}
1 change: 0 additions & 1 deletion snap-wrappers/bin/launch-multipass
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/bin/sh

. client-common.sh
link_autostart

exec "$SNAP/bin/multipass" "$@"
16 changes: 0 additions & 16 deletions snap-wrappers/bin/launch-multipass-gui

This file was deleted.

1 change: 1 addition & 0 deletions snap/snapcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ apps:
- network
gui:
desktop: usr/share/applications/multipass.gui.desktop
autostart: multipass.gui.autostart.desktop
extensions: [ gnome ]
environment: &_client-environment
XDG_DATA_HOME: $SNAP_USER_DATA
Expand Down
26 changes: 26 additions & 0 deletions src/client/gui/assets/com.canonical.multipass.gui.autostart.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.canonical.multipass.gui.autostart</string>

<key>Program</key>
<string>/Applications/Multipass.app/Contents/MacOS/Multipass</string>

<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>

<key>RunAtLoad</key>
<true/>

<key>ThrottleInterval</key>
<integer>0</integer>

<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[Desktop Entry]
Name=Multipass
Exec=multipass.gui --autostarting
Exec=multipass.gui
Icon=multipass.gui
Type=Application
Terminal=false
Categories=Utility;
98 changes: 65 additions & 33 deletions src/client/gui/lib/catalogue/catalogue.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import 'dart:async';

import 'package:basics/basics.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart' hide ImageInfo;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grpc/grpc.dart';

import '../providers.dart';
import 'image_card.dart';
import 'launch_panel.dart';

final imagesProvider = FutureProvider<List<ImageInfo>>((ref) async {
return ref.watch(daemonAvailableProvider)
? await ref
.watch(grpcClientProvider)
.find(blueprints: false)
.then((r) => sortImages(r.imagesInfo))
: ref.state.valueOrNull ?? await Completer<List<ImageInfo>>().future;
if (ref.watch(daemonAvailableProvider)) {
final images = ref
.watch(grpcClientProvider)
.find(blueprints: false)
.then((r) => sortImages(r.imagesInfo));
// artificial delay so that we can see the loading spinner a bit
// otherwise the reply arrives too quickly and we only see a flash of the spinner
await Future.delayed(1.seconds);
return await images;
}

return ref.state.valueOrNull ?? await Completer<List<ImageInfo>>().future;
});

// sorts the images in a more user-friendly way
Expand Down Expand Up @@ -65,32 +73,28 @@ class CatalogueScreen extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final images = ref.watch(imagesProvider).valueOrNull ?? const [];
final imageList = SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 10),
child: const Text(
'Images',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
LayoutBuilder(builder: (_, constraints) {
const minCardWidth = 285;
const spacing = 32.0;
final nCards = constraints.maxWidth ~/ minCardWidth;
final whiteSpace = spacing * (nCards - 1);
final cardWidth = (constraints.maxWidth - whiteSpace) / nCards;
return Wrap(
runSpacing: spacing,
spacing: spacing,
children:
images.map((image) => ImageCard(image, cardWidth)).toList(),
);
}),
const SizedBox(height: 32),
]),
);
final content = ref.watch(imagesProvider).when(
skipLoadingOnRefresh: false,
data: _buildCatalogue,
error: (error, _) {
final errorMessage = error is GrpcError ? error.message : error;
return Center(
child: Column(children: [
const SizedBox(height: 32),
Text(
'Failed to retrieve images: $errorMessage',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => ref.invalidate(imagesProvider),
child: const Text('Refresh'),
),
]),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
);

final welcomeText = Container(
constraints: const BoxConstraints(maxWidth: 500),
Expand Down Expand Up @@ -118,10 +122,38 @@ class CatalogueScreen extends ConsumerWidget {
children: [
welcomeText,
const Divider(),
Expanded(child: imageList),
Expanded(child: content),
],
),
),
);
}

Widget _buildCatalogue(List<ImageInfo> images) {
return SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 10),
child: const Text(
'Images',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
LayoutBuilder(builder: (_, constraints) {
const minCardWidth = 285;
const spacing = 32.0;
final nCards = constraints.maxWidth ~/ minCardWidth;
final whiteSpace = spacing * (nCards - 1);
final cardWidth = (constraints.maxWidth - whiteSpace) / nCards;
return Wrap(
runSpacing: spacing,
spacing: spacing,
children:
images.map((image) => ImageCard(image, cardWidth)).toList(),
);
}),
const SizedBox(height: 32),
]),
);
}
}
69 changes: 36 additions & 33 deletions src/client/gui/lib/catalogue/image_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,42 +56,45 @@ class ImageCard extends ConsumerWidget {
child: Text(image.codename),
),
),
Row(
children: [
OutlinedButton(
onPressed: () {
final grpcClient = ref.read(grpcClientProvider);
final name = ref.read(randomNameProvider);
final request = LaunchRequest(instanceName: name);
final aliasInfo = image.aliasesInfo.first;
request.image = aliasInfo.alias;
if (aliasInfo.hasRemoteName()) {
request.remoteName = aliasInfo.remoteName;
}
Row(children: [
OutlinedButton(
onPressed: () {
final name = ref.read(randomNameProvider);
final aliasInfo = image.aliasesInfo.first;
final request = LaunchRequest(
instanceName: name,
image: aliasInfo.alias,
remoteName:
aliasInfo.hasRemoteName() ? aliasInfo.remoteName : null,
);

final stream = grpcClient.launch(request);
ref.read(launchOperationProvider.notifier).state =
(stream, name, imageName(image));
Scaffold.of(context).openEndDrawer();
},
child: const Text('Launch'),
),
const SizedBox(width: 16),
OutlinedButton(
onPressed: () {
ref.read(launchingImageProvider.notifier).state = image;
Scaffold.of(context).openEndDrawer();
},
child: SvgPicture.asset(
'assets/settings.svg',
colorFilter: const ColorFilter.mode(
Colors.black,
BlendMode.srcIn,
),
final grpcClient = ref.read(grpcClientProvider);
final operation = LaunchOperation(
stream: grpcClient.launch(request),
name: name,
image: imageName(image),
);

ref.read(launchOperationProvider.notifier).state = operation;
Scaffold.of(context).openEndDrawer();
},
child: const Text('Launch'),
),
const SizedBox(width: 16),
OutlinedButton(
onPressed: () {
ref.read(launchingImageProvider.notifier).state = image;
Scaffold.of(context).openEndDrawer();
},
child: SvgPicture.asset(
'assets/settings.svg',
colorFilter: const ColorFilter.mode(
Colors.black,
BlendMode.srcIn,
),
),
],
),
),
]),
],
),
),
Expand Down
14 changes: 10 additions & 4 deletions src/client/gui/lib/catalogue/launch_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,12 @@ class LaunchForm extends ConsumerWidget {
}

final grpcClient = ref.read(grpcClientProvider);
final stream = grpcClient.launch(launchRequest, mountRequests);
ref.read(launchOperationProvider.notifier).state =
(stream, launchRequest.instanceName, imageName(imageInfo));
final operation = LaunchOperation(
stream: grpcClient.launch(launchRequest, mountRequests),
name: launchRequest.instanceName,
image: imageName(imageInfo),
);
ref.read(launchOperationProvider.notifier).state = operation;
},
child: const Text('Launch'),
);
Expand Down Expand Up @@ -156,7 +159,10 @@ class LaunchForm extends ConsumerWidget {
const SizedBox(height: 40),
bridgedSwitch,
const SizedBox(height: 40),
MountPointList(onSaved: (requests) => mountRequests.addAll(requests)),
MountPointList(
width: 300,
onSaved: (requests) => mountRequests.addAll(requests),
),
const SizedBox(height: 40),
Row(children: [
launchButton,
Expand Down
23 changes: 10 additions & 13 deletions src/client/gui/lib/catalogue/launch_operation_progress.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,13 @@ class LaunchOperationProgress extends ConsumerWidget {
padding: const EdgeInsets.all(16),
alignment: Alignment.topLeft,
child: StreamBuilder(
stream: stream.doOnData((r) {
if (r == null) {
Scaffold.of(context).closeEndDrawer();
ref.read(sidebarKeyProvider.notifier).state = 'vm-$name';
Timer(
const Duration(milliseconds: 200),
() => ref.invalidate(launchOperationProvider),
);
}
stream: stream.doOnData((reply) {
if (reply != null) return;
Scaffold.of(context).closeEndDrawer();
Timer(250.milliseconds, () {
ref.invalidate(launchOperationProvider);
ref.read(sidebarKeyProvider.notifier).set('vm-$name');
});
}),
builder: (_, snapshot) {
if (snapshot.hasError) {
Expand All @@ -55,10 +53,9 @@ class LaunchOperationProgress extends ConsumerWidget {
icon: const Icon(Icons.close),
onPressed: () {
Scaffold.of(context).closeEndDrawer();
Timer(
const Duration(milliseconds: 200),
() => ref.invalidate(launchOperationProvider),
);
Timer(250.milliseconds, () {
ref.invalidate(launchOperationProvider);
});
},
),
),
Expand Down
18 changes: 14 additions & 4 deletions src/client/gui/lib/catalogue/launch_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ import '../providers.dart';
import 'launch_form.dart';
import 'launch_operation_progress.dart';

final launchOperationProvider =
StateProvider<(Stream<Either<LaunchReply, MountReply>?>, String, String)?>(
(_) => null);
class LaunchOperation {
final Stream<Either<LaunchReply, MountReply>?> stream;
final String name;
final String image;

LaunchOperation({
required this.stream,
required this.name,
required this.image,
});
}

final launchOperationProvider = StateProvider<LaunchOperation?>((_) => null);

class LaunchPanel extends ConsumerWidget {
const LaunchPanel({super.key});
Expand All @@ -20,7 +30,7 @@ class LaunchPanel extends ConsumerWidget {
return LaunchForm();
}

final (stream, name, image) = values;
final LaunchOperation(:stream, :name, :image) = values;
return FocusableActionDetector(
autofocus: true,
actions: {
Expand Down
Loading

0 comments on commit afd2d19

Please sign in to comment.