Prechádzať zdrojové kódy

feat(mobile): persistent API URL and client name

    * feat: removed useless sidebar header box
    * feat: flutter analyze as part of unit test runs
    * fix: observable API url
    * fix: query in root api URLs
    * feat: verbose log of HTTP requests
    * feat: store API URL and client name in preferences
Fela Maslen 4 rokov pred
rodič
commit
47433a81e4

+ 1 - 1
Jenkinsfile

@@ -47,7 +47,7 @@ node {
                 sh "docker run --rm -e 'CI=1' -e 'REACT_APP_API_URL=http://my-api.url:1234' docker.fela.space/gmus-web-builder:latest sh -c 'make test'"
               },
               "gmus-mobile:unit tests": {
-                sh "docker run --rm ${IMAGE_MOBILE} sh -c 'flutter test'"
+                sh "docker run --rm ${IMAGE_MOBILE} sh -c 'flutter analyze && flutter test'"
               },
               "gmus-backend:tests": {
                 sh "docker run --rm --link ${pg.id}:db --link ${redis.id}:redis ${IMAGE_BACKEND} sh -c 'make test.ci'"

+ 1 - 0
gmus-backend/docker-compose.yml

@@ -19,6 +19,7 @@ services:
     container_name: gmus-backend
     build:
       context: .
+    command: "make run.server"
     volumes:
       - .:/app
       - /app/bin

+ 1 - 0
gmus-backend/pkg/server/handler.go

@@ -16,6 +16,7 @@ func routeHandler(
 ) func(w http.ResponseWriter, r *http.Request) {
 
 	return func(w http.ResponseWriter, r *http.Request) {
+		l.Verbose("[%s] %s\n", r.Method, r.URL);
 		err := handler(l, rdb, w, r)
 
 		if err != nil {

+ 0 - 1
gmus-mobile/.env.example

@@ -1,2 +1 @@
 DART_ENV=development
-API_URL=10.0.2.2:3003

+ 2 - 1
gmus-mobile/Makefile

@@ -30,4 +30,5 @@ run:
 	flutter run --dart-define=API_URL="${GMUS_MOBILE_API_URL}"
 
 test.flutter:
-	flutter test
+	@flutter analyze
+	@flutter test

+ 8 - 4
gmus-mobile/lib/components/albums.dart

@@ -9,20 +9,22 @@ import '../utils/url.dart';
 import './spinner.dart';
 
 class Albums extends StatefulWidget {
+  final String apiUrl;
   final String artist;
   final void Function(String, String) onSelect;
 
   Albums({
+    @required this.apiUrl,
     @required this.artist,
     @required this.onSelect,
   });
 
   @override
-  _AlbumsWidgetState createState() => _AlbumsWidgetState(artist: this.artist, onSelect: this.onSelect);
+  _AlbumsWidgetState createState() => _AlbumsWidgetState(apiUrl: this.apiUrl, artist: this.artist, onSelect: this.onSelect);
 }
 
-Future<List<String>> fetchAlbums(String artist) async {
-  final response = await http.get(formattedUrl('/albums', {
+Future<List<String>> fetchAlbums(String apiUrl, String artist) async {
+  final response = await http.get(formattedUrl(apiUrl, '/albums', {
     'artist': artist,
   }));
 
@@ -36,12 +38,14 @@ Future<List<String>> fetchAlbums(String artist) async {
 const allAlbums = 'All albums';
 
 class _AlbumsWidgetState extends State<Albums> {
+  final String apiUrl;
   final String artist;
   Future<List<String>> albums;
 
   final void Function(String, String) onSelect;
 
   _AlbumsWidgetState({
+    @required this.apiUrl,
     @required this.artist,
     @required this.onSelect,
   });
@@ -49,7 +53,7 @@ class _AlbumsWidgetState extends State<Albums> {
   @override
   void initState() {
     super.initState();
-    albums = fetchAlbums(this.artist);
+    albums = fetchAlbums(this.apiUrl, this.artist);
   }
 
   @override

+ 47 - 0
gmus-mobile/lib/components/apiurl.dart

@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../controller.dart';
+import '../socket.dart' as socket;
+
+class _SetApiUrl extends StatefulWidget {
+  @override
+  _SetApiUrlState createState() => _SetApiUrlState();
+}
+
+class _SetApiUrlState extends State<_SetApiUrl> {
+  String apiUrl;
+  Controller controller = Get.find();
+
+  @override
+  Widget build(BuildContext context) {
+    if (controller == null) {
+      return null;
+    }
+    return Dialog(child: Column(
+      children: <Widget>[
+        Text('Current value: ${controller.apiUrl}'),
+        TextField(
+          onChanged: (newValue) {
+            this.apiUrl = newValue;
+          },
+          decoration: InputDecoration(
+              border: InputBorder.none,
+              hintText: 'Set API URL',
+            ),
+        ),
+        TextButton(
+          child: Text('Set'),
+          onPressed: () {
+            socket.setApiUrl(controller, this.apiUrl);
+            Navigator.pop(context);
+          },
+        ),
+      ],
+    ));
+  }
+}
+
+Widget widgetBuilderSetApiUrl(BuildContext context) {
+  return _SetApiUrl();
+}

+ 9 - 4
gmus-mobile/lib/components/artists.dart

@@ -8,18 +8,21 @@ import '../utils/url.dart';
 import './spinner.dart';
 
 class Artists extends StatefulWidget {
+  final String apiUrl;
   final void Function(String) onSelect;
 
   Artists({
+    @required this.apiUrl,
     @required this.onSelect,
   });
 
   @override
-  _ArtistsWidgetState createState() => _ArtistsWidgetState(onSelect: this.onSelect);
+  _ArtistsWidgetState createState() => _ArtistsWidgetState(apiUrl: this.apiUrl, onSelect: this.onSelect);
 }
 
-Future<List<String>> fetchArtists() async {
-  final response = await http.get(formattedUrl('/artists'));
+Future<List<String>> fetchArtists(String apiUrl) async {
+
+  final response = await http.get(formattedUrl(apiUrl, '/artists'));
 
   if (response.statusCode == 200) {
     return List<String>.from(jsonDecode(response.body)['artists']);
@@ -29,18 +32,20 @@ Future<List<String>> fetchArtists() async {
 }
 
 class _ArtistsWidgetState extends State<Artists> {
+  String apiUrl;
   Future<List<String>> artists;
 
   final void Function(String) onSelect;
 
   _ArtistsWidgetState({
+    @required this.apiUrl,
     @required this.onSelect,
   });
 
   @override
   void initState() {
     super.initState();
-    artists = fetchArtists();
+    artists = fetchArtists(this.apiUrl);
   }
 
   @override

+ 10 - 2
gmus-mobile/lib/components/browser.dart

@@ -49,9 +49,17 @@ class _BrowserWidgetState extends State<Browser> {
     return PageView(
       controller: pageController,
       children: <Widget>[
-        Artists(onSelect: this.onSelectArtist),
-        Obx(() => Albums(artist: this.selectedArtist.value, onSelect: this.onSelectAlbum)),
+        Obx(() => Artists(
+          apiUrl: controller.apiUrl.value,
+          onSelect: this.onSelectArtist,
+        )),
+        Obx(() => Albums(
+          apiUrl: controller.apiUrl.value,
+          artist: this.selectedArtist.value,
+          onSelect: this.onSelectAlbum,
+        )),
         Obx(() => Songs(
+          apiUrl: controller.apiUrl.value,
           artist: this.selectedArtist.value,
           album: this.selectedAlbum.value,
           onSelect: this.onSelectSong,

+ 7 - 0
gmus-mobile/lib/components/content.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
+import 'package:gmus/components/spinner.dart';
 
 import '../controller.dart';
 
@@ -10,11 +11,17 @@ class Content extends StatelessWidget {
   final Controller controller = Get.find();
   @override
   Widget build(BuildContext context) {
+    if (controller == null) {
+      return null;
+    }
     return Obx(() {
       var loggedIn = controller.uniqueName.value.length > 0;
       if (!loggedIn) {
         return Identify();
       }
+      if (!controller.connected.isTrue) {
+        return CenterSpinner();
+      }
 
       return UI();
     });

+ 14 - 2
gmus-mobile/lib/components/player.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
+import 'package:gmus/socket.dart';
 
 import '../controller.dart';
 
@@ -15,8 +16,19 @@ class GmusPlayer extends StatelessWidget {
       return Row(
         children: [
           TextButton(
-              child: Text(playPauseButtonText),
-              onPressed: controller.playPause,
+            child: Text(playPauseButtonText),
+            onPressed: controller.playPause,
+          ),
+          TextButton(
+            child: Text('Disconnect'),
+            onPressed: () => disconnect(this.controller),
+          ),
+          TextButton(
+            child: Text('Reconnect'),
+            onPressed: () {
+              disconnect(this.controller);
+              connect(this.controller);
+            },
           ),
         ],
       );

+ 8 - 3
gmus-mobile/lib/components/songs.dart

@@ -10,11 +10,13 @@ import '../utils/url.dart';
 import './spinner.dart';
 
 class Songs extends StatefulWidget {
+  final String apiUrl;
   final String artist;
   final String album;
   final void Function(int) onSelect;
 
   Songs({
+    @required this.apiUrl,
     @required this.artist, // can be an empty string
     @required this.album, // can be an empty string
     @required this.onSelect,
@@ -22,14 +24,15 @@ class Songs extends StatefulWidget {
 
   @override
   _SongsWidgetState createState() => _SongsWidgetState(
+      apiUrl: this.apiUrl,
       artist: this.artist,
       album: this.album,
       onSelect: this.onSelect,
     );
 }
 
-Future<List<Song>> fetchSongs(String artist) async {
-  final response = await http.get(formattedUrl('/songs', {
+Future<List<Song>> fetchSongs(String apiUrl, String artist) async {
+  final response = await http.get(formattedUrl(apiUrl, '/songs', {
     'artist': artist,
   }));
 
@@ -46,6 +49,7 @@ Future<List<Song>> fetchSongs(String artist) async {
 }
 
 class _SongsWidgetState extends State<Songs> {
+  final String apiUrl;
   final String artist;
   final String album;
   Future<List<Song>> songs;
@@ -53,6 +57,7 @@ class _SongsWidgetState extends State<Songs> {
   final void Function(int) onSelect;
 
   _SongsWidgetState({
+    @required this.apiUrl,
     @required this.artist,
     @required this.album,
     @required this.onSelect,
@@ -61,7 +66,7 @@ class _SongsWidgetState extends State<Songs> {
   @override
   void initState() {
     super.initState();
-    songs = fetchSongs(this.artist);
+    songs = fetchSongs(this.apiUrl, this.artist);
   }
 
   @override

+ 5 - 2
gmus-mobile/lib/components/status.dart

@@ -10,9 +10,12 @@ class StatusBarWrapped extends StatelessWidget {
   });
   @override
   Widget build(BuildContext context) {
+    if (controller == null) {
+      return null;
+    }
     return Obx(() {
-      var uniqueName = controller.uniqueName.value;
-      if (uniqueName.length == 0) {
+      var connected = controller.connected.value;
+      if (!connected) {
         return Text("Disconnected");
       }
 

+ 1 - 1
gmus-mobile/lib/components/ui.dart

@@ -6,7 +6,7 @@ import '../controller.dart';
 import './browser.dart';
 import './player.dart';
 
-// Main UI once identified
+// Main UI once identified and connected
 class UI extends StatelessWidget {
   final Controller controller = Get.find();
   @override

+ 0 - 3
gmus-mobile/lib/config.dart

@@ -1,8 +1,5 @@
 import 'package:flutter_dotenv/flutter_dotenv.dart';
 
-const apiUrl = String.fromEnvironment('API_URL') ?? 'localhost:3000';
-
 final config = {
   'isDevelopment': env['DART_ENV'] == 'development',
-  'apiUrl': env['API_URL'] ?? apiUrl,
 };

+ 18 - 40
gmus-mobile/lib/controller.dart

@@ -4,6 +4,7 @@ import 'package:get/get.dart';
 import 'package:web_socket_channel/io.dart';
 
 import './actions.dart' as actions;
+import './preferences.dart' as Preferences;
 
 class Player {
   double currentTime = 0;
@@ -36,57 +37,34 @@ class Client {
 }
 
 class Controller extends GetxController {
+  RxString apiUrl = ''.obs;
+  RxBool connected = false.obs;
+
   RxString name = ''.obs;
   RxString uniqueName = ''.obs;
 
   IOWebSocketChannel channel;
 
   Rx<Player> player = new Player().obs;
-  RxList<Client> clients;
+  RxList<Client> clients = <Client>[].obs;
+
+  Controller({
+    this.apiUrl,
+    this.name,
+  });
+
+  setApiUrl(String apiUrl) {
+    this.apiUrl = apiUrl.obs;
+    Preferences.setApiUrl(apiUrl);
+  }
 
   setName(String newName) {
     name.value = newName;
+    Preferences.setClientName(newName);
   }
-  setUniqueName(String newUniqueName) {
-    uniqueName.value = newUniqueName;
-  }
-
-  onRemoteMessage(String message) {
-    var action = jsonDecode(message);
-    switch (action['type']) {
-      case actions.CLIENT_LIST_UPDATED:
-        var payload = action['payload'];
-
-        List<Client> clientList = [];
-        for (var i = 0; i < payload.length; i++) {
-          clientList.add(new Client(
-            payload[i]['name'],
-            payload[i]['lastPing'],
-          ));
-        }
 
-        this.clients = clientList.obs;
-
-        break;
-
-      case actions.STATE_SET:
-        Player nextPlayer = new Player();
-
-        nextPlayer.currentTime = action['payload']['currentTime'].toDouble();
-        nextPlayer.seekTime = action['payload']['seekTime'].toDouble();
-        nextPlayer.master = action['payload']['master'];
-        nextPlayer.songId = action['payload']['songId'];
-        nextPlayer.playing = action['payload']['playing'];
-
-        nextPlayer.queue = [];
-        for (var i = 0; i < action['payload']['queue'].length; i++) {
-          nextPlayer.queue.add(action['payload']['queue'][i]);
-        }
-
-        this.player.value = nextPlayer;
-
-        break;
-    }
+  setClients(List<Client> newClients) {
+    this.clients.assignAll(newClients);
   }
 
   void _remoteDispatch(String action) {

+ 29 - 2
gmus-mobile/lib/main.dart

@@ -3,17 +3,27 @@ import 'dart:io';
 import 'package:flutter_dotenv/flutter_dotenv.dart' as DotEnv;
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
+import 'package:gmus/components/apiurl.dart';
 
 import './config.dart';
 import './controller.dart';
+import './preferences.dart';
 
 import './components/content.dart';
 import './components/status.dart';
 
 class Gmus extends StatelessWidget {
+  final Preferences storedPreferences;
+  Gmus({
+    @required this.storedPreferences,
+  });
+
   @override
   Widget build(BuildContext context) {
-    Get.put(Controller());
+    Get.put(Controller(
+      apiUrl: this.storedPreferences.apiUrl.obs,
+      name: this.storedPreferences.clientName.obs,
+    ));
 
     return Scaffold(
       appBar: AppBar(
@@ -29,6 +39,22 @@ class Gmus extends StatelessWidget {
               ],
           ),
         ),
+      drawer: Drawer(
+          child: ListView(
+            padding: EdgeInsets.only(top: 40.0),
+            children: <Widget>[
+              ListTile(
+                title: Text('Set API URL'),
+                onTap: () {
+                  showDialog(
+                    context: context,
+                    builder: widgetBuilderSetApiUrl,
+                  );
+                },
+              ),
+            ],
+          ),
+        ),
       );
   }
 }
@@ -49,5 +75,6 @@ Future<void> main() async {
     HttpOverrides.global = new MyHttpOverrides();
   }
 
-  runApp(GetMaterialApp(home: Gmus()));
+  getPreferences().then((preferences) =>
+      runApp(GetMaterialApp(home: Gmus(storedPreferences: preferences))));
 }

+ 31 - 0
gmus-mobile/lib/preferences.dart

@@ -0,0 +1,31 @@
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:flutter/material.dart';
+
+class Preferences {
+  final String apiUrl;
+  final String clientName;
+
+  Preferences({
+    @required this.apiUrl,
+    @required this.clientName,
+  });
+}
+
+Future<Preferences> getPreferences() async {
+  SharedPreferences preferences = await SharedPreferences.getInstance();
+  return Preferences(
+    apiUrl: preferences.get('apiUrl'),
+    clientName: preferences.get('clientName'),
+  );
+}
+
+setApiUrl(String apiUrl) async {
+  SharedPreferences preferences = await SharedPreferences.getInstance();
+  print('setting preferences apiUrl=$apiUrl');
+  await preferences.setString('apiUrl', apiUrl);
+}
+
+setClientName(String clientName) async {
+  SharedPreferences preferences = await SharedPreferences.getInstance();
+  await preferences.setString('clientName', clientName);
+}

+ 74 - 12
gmus-mobile/lib/socket.dart

@@ -1,46 +1,108 @@
+import 'dart:async';
 import 'dart:convert';
 
 import 'package:nanoid/nanoid.dart';
 import 'package:web_socket_channel/io.dart';
 
-import './config.dart';
+import './actions.dart' as actions;
 import './controller.dart';
 
-String getWebSocketUrl() {
-  return "wss://${config['apiUrl']}/pubsub";
+String getWebSocketUrl(String apiUrl) {
+  return "wss://$apiUrl/pubsub";
 }
 
 String getUniqueName(String name) {
   return "$name-${nanoid(5)}";
 }
 
-const socketKeepaliveTimeoutMs = 20000;
+const socketKeepaliveTimeoutMs = 30000;
 
-Future keepalive(IOWebSocketChannel channel) {
-  return new Future.delayed(const Duration(milliseconds: socketKeepaliveTimeoutMs), () {
+void keepalive(IOWebSocketChannel channel) {
+  var future = new Future.delayed(const Duration(milliseconds: socketKeepaliveTimeoutMs));
+  void ping(dynamic data) {
     channel.sink.add(jsonEncode({'type': 'PING'}));
-
     keepalive(channel);
+  }
+  var subscription = future.asStream().listen(ping);
+
+  channel.sink.done.whenComplete(() {
+    subscription.cancel();
   });
 }
 
-void connect(Controller controller) async {
-  if (controller.name.value.length == 0) {
+void onRemoteMessage(Controller controller, String message) {
+  var action = jsonDecode(message);
+  switch (action['type']) {
+    case actions.CLIENT_LIST_UPDATED:
+      var payload = action['payload'];
+
+      List<Client> clientList = [];
+      for (var i = 0; i < payload.length; i++) {
+        clientList.add(new Client(
+          payload[i]['name'],
+          payload[i]['lastPing'],
+        ));
+      }
+
+      controller.setClients(clientList);
+
+      break;
+
+    case actions.STATE_SET:
+      Player nextPlayer = new Player();
+
+      nextPlayer.currentTime = action['payload']['currentTime'].toDouble();
+      nextPlayer.seekTime = action['payload']['seekTime'].toDouble();
+      nextPlayer.master = action['payload']['master'];
+      nextPlayer.songId = action['payload']['songId'];
+      nextPlayer.playing = action['payload']['playing'];
+
+      nextPlayer.queue = [];
+      for (var i = 0; i < action['payload']['queue'].length; i++) {
+        nextPlayer.queue.add(action['payload']['queue'][i]);
+      }
+
+      controller.player.value = nextPlayer;
+
+      break;
+  }
+}
+
+void connect(Controller controller) {
+  if (controller == null ||
+      controller.name.value.length == 0 ||
+      controller.apiUrl.value.length == 0) {
     return;
   }
 
   final String uniqueName = getUniqueName(controller.name.value);
-  controller.setUniqueName(uniqueName);
+  controller.uniqueName.value = uniqueName;
 
-  final String webSocketUrl = getWebSocketUrl();
+  final String webSocketUrl = getWebSocketUrl(controller.apiUrl.value);
   final String pubsubUrl = "$webSocketUrl?client-name=$uniqueName";
 
   var channel = IOWebSocketChannel.connect(Uri.parse(pubsubUrl));
   controller.channel = channel;
 
   channel.stream.listen((message) {
-    controller.onRemoteMessage(message);
+    onRemoteMessage(controller, message);
   });
 
+  controller.connected.value = true;
+
   keepalive(channel);
 }
+
+void disconnect(Controller controller) {
+  controller.connected.value = false;
+  if (controller.channel != null) {
+    controller.channel.sink.close();
+  }
+  controller.uniqueName.value = '';
+}
+
+void setApiUrl(Controller controller, String apiUrl) {
+  disconnect(controller);
+  controller.setApiUrl(apiUrl);
+  connect(controller);
+}

+ 2 - 6
gmus-mobile/lib/utils/url.dart

@@ -1,10 +1,6 @@
-import '../config.dart';
-
-Uri formattedUrl(String path, [Map<String, dynamic> query]) {
-  String apiUrl = config['apiUrl'];
-
+Uri formattedUrl(String apiUrl, String path, [Map<String, dynamic> query]) {
   if (apiUrl.indexOf('/') == -1) {
-    return Uri.https(apiUrl, path);
+    return Uri.https(apiUrl, path, query);
   }
 
   String host = apiUrl.substring(0, apiUrl.indexOf('/'));

+ 118 - 0
gmus-mobile/pubspec.lock

@@ -141,6 +141,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.2.0"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.3"
   file:
     dependency: transitive
     description:
@@ -179,6 +186,11 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
   get:
     dependency: "direct main"
     description:
@@ -214,6 +226,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.19"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.6.3"
   logging:
     dependency: transitive
     description:
@@ -263,6 +282,27 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.8.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.1+2"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.4"
+  path_provider_windows:
+    dependency: transitive
+    description:
+      name: path_provider_windows
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.4+3"
   pedantic:
     dependency: transitive
     description:
@@ -277,6 +317,27 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "3.1.0"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.0"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.3"
+  process:
+    dependency: transitive
+    description:
+      name: process
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.1.0"
   pub_semver:
     dependency: transitive
     description:
@@ -284,6 +345,48 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.0.0"
+  shared_preferences:
+    dependency: "direct main"
+    description:
+      name: shared_preferences
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.5.12+4"
+  shared_preferences_linux:
+    dependency: transitive
+    description:
+      name: shared_preferences_linux
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.2+4"
+  shared_preferences_macos:
+    dependency: transitive
+    description:
+      name: shared_preferences_macos
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.1+11"
+  shared_preferences_platform_interface:
+    dependency: transitive
+    description:
+      name: shared_preferences_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.4"
+  shared_preferences_web:
+    dependency: transitive
+    description:
+      name: shared_preferences_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.2+7"
+  shared_preferences_windows:
+    dependency: transitive
+    description:
+      name: shared_preferences_windows
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.2+3"
   sky_engine:
     dependency: transitive
     description: flutter
@@ -366,6 +469,20 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.2.0"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.7.4+1"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.2"
   xml:
     dependency: transitive
     description:
@@ -382,3 +499,4 @@ packages:
     version: "2.2.1"
 sdks:
   dart: ">=2.12.0-0.0 <3.0.0"
+  flutter: ">=1.12.13+hotfix.5"

+ 1 - 0
gmus-mobile/pubspec.yaml

@@ -28,6 +28,7 @@ dependencies:
   get:
   http:
   nanoid:
+  shared_preferences:
   web_socket_channel:
 
 dev_dependencies:

+ 2 - 1
gmus-mobile/test/components/status_test.dart

@@ -31,7 +31,8 @@ void main() {
 
   testWidgets('Status bar should render a connected message with name', (WidgetTester tester) async {
     Controller controller = Controller();
-    controller.setUniqueName('mob-DvaU1');
+    controller.uniqueName = 'mob-DvaU1'.obs;
+    controller.connected.value = true;
 
     await tester.pumpWidget(TestStatusBar(controller: controller));
 

+ 6 - 5
gmus-mobile/test/controller_test.dart

@@ -1,6 +1,7 @@
 import 'package:flutter_test/flutter_test.dart';
 import 'package:gmus/actions.dart';
 import 'package:gmus/controller.dart';
+import 'package:gmus/socket.dart';
 import 'package:mockito/mockito.dart';
 import 'package:web_socket_channel/io.dart';
 import 'package:web_socket_channel/web_socket_channel.dart';
@@ -14,7 +15,7 @@ void main() {
         test('client list should be updated', () {
           Controller controller = Controller();
 
-          controller.onRemoteMessage(message);
+          onRemoteMessage(controller, message);
 
           expect(controller.clients.length, 2);
 
@@ -31,7 +32,7 @@ void main() {
 
         test('player state should be updated', () {
           Controller controller = Controller();
-          controller.onRemoteMessage(message);
+          onRemoteMessage(controller, message);
 
           expect(controller.player.value.master, 'new-master-client');
           expect(controller.player.value.songId, 7123);
@@ -42,7 +43,7 @@ void main() {
 
         test('queue should be updated', () {
           Controller controller = Controller();
-          controller.onRemoteMessage(message);
+          onRemoteMessage(controller, message);
 
           expect(controller.player.value.queue.length, 1);
           expect(controller.player.value.queue[0], 9750);
@@ -120,7 +121,7 @@ class MockChannel extends Mock implements IOWebSocketChannel {
 
 Controller mockControllerAsMaster() {
   Controller controllerAsMaster = Controller();
-  controllerAsMaster.setUniqueName('my-client-name-master');
+  controllerAsMaster.uniqueName.value = 'my-client-name-master';
   controllerAsMaster.player.value.master = 'my-client-name-master';
 
   controllerAsMaster.channel = MockChannel(sink: MockWebSocketSink());
@@ -130,7 +131,7 @@ Controller mockControllerAsMaster() {
 
 Controller mockControllerAsSlave() {
   Controller controllerAsSlave = Controller();
-  controllerAsSlave.setUniqueName('my-client-name-slave');
+  controllerAsSlave.uniqueName.value = 'my-client-name-slave';
   controllerAsSlave.player.value.master = 'other-client-name-master';
 
   controllerAsSlave.channel = MockChannel(sink: MockWebSocketSink());

+ 36 - 0
gmus-mobile/test/utils/url_test.dart

@@ -0,0 +1,36 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gmus/utils/url.dart';
+
+void main() {
+  test('formattedUrl should format a root URL', () {
+    expect(
+      formattedUrl('my.api.com', '/foo/bar').toString(),
+      'https://my.api.com/foo/bar',
+    );
+  });
+
+  test('formattedUrl should format a root URL with query', () {
+    expect(
+      formattedUrl('my.api.com', '/foo/bar', {
+        'baz': 'yes',
+      }).toString(),
+      'https://my.api.com/foo/bar?baz=yes',
+    );
+  });
+
+  test('formattedUrl should format a non-root URL', () {
+    expect(
+      formattedUrl('my.api.com/api', '/foo/bar').toString(),
+      'https://my.api.com/api/foo/bar',
+    );
+  });
+
+  test('formattedUrl should format a non-root URL with query', () {
+    expect(
+      formattedUrl('my.api.com/api', '/foo/bar', {
+        'baz': 'yes',
+      }).toString(),
+      'https://my.api.com/api/foo/bar?baz=yes',
+    );
+  });
+}