Tim Jones 2 недель назад
Родитель
Сommit
0ce131c821
100 измененных файлов с 5626 добавлено и 0 удалено
  1. 3 0
      .gitignore
  2. 76 0
      RackPeek.Domain/Api/InventoryDocumentJson.cs
  3. 7 0
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  4. 83 0
      RackPeek.Web/Api/InventoryReadEndpoints.cs
  5. 1 0
      RackPeek.Web/Program.cs
  6. 22 0
      Shared.Rcl/wwwroot/raw_docs/inventory-api.md
  7. 210 0
      Tests/Api/InventoryReadEndpointTests.cs
  8. 45 0
      mobile/.gitignore
  9. 33 0
      mobile/.metadata
  10. 24 0
      mobile/README.md
  11. 28 0
      mobile/analysis_options.yaml
  12. 14 0
      mobile/android/.gitignore
  13. 44 0
      mobile/android/app/build.gradle.kts
  14. 45 0
      mobile/android/app/src/main/AndroidManifest.xml
  15. 5 0
      mobile/android/app/src/main/kotlin/com/rackpeek/rackpeek_mobile/MainActivity.kt
  16. 12 0
      mobile/android/app/src/main/res/drawable-v21/launch_background.xml
  17. 12 0
      mobile/android/app/src/main/res/drawable/launch_background.xml
  18. BIN
      mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  19. BIN
      mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  20. BIN
      mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  21. BIN
      mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  22. BIN
      mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  23. 18 0
      mobile/android/app/src/main/res/values-night/styles.xml
  24. 18 0
      mobile/android/app/src/main/res/values/styles.xml
  25. 7 0
      mobile/android/app/src/profile/AndroidManifest.xml
  26. 24 0
      mobile/android/build.gradle.kts
  27. 2 0
      mobile/android/gradle.properties
  28. 5 0
      mobile/android/gradle/wrapper/gradle-wrapper.properties
  29. 26 0
      mobile/android/settings.gradle.kts
  30. 34 0
      mobile/ios/.gitignore
  31. 24 0
      mobile/ios/Flutter/AppFrameworkInfo.plist
  32. 2 0
      mobile/ios/Flutter/Debug.xcconfig
  33. 2 0
      mobile/ios/Flutter/Release.xcconfig
  34. 42 0
      mobile/ios/Podfile
  35. 23 0
      mobile/ios/Podfile.lock
  36. 732 0
      mobile/ios/Runner.xcodeproj/project.pbxproj
  37. 7 0
      mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
  38. 8 0
      mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  39. 8 0
      mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  40. 101 0
      mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  41. 10 0
      mobile/ios/Runner.xcworkspace/contents.xcworkspacedata
  42. 8 0
      mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  43. 8 0
      mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  44. 16 0
      mobile/ios/Runner/AppDelegate.swift
  45. 122 0
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
  46. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
  47. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
  48. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
  49. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
  50. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
  51. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
  52. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
  53. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
  54. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
  55. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
  56. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
  57. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
  58. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
  59. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
  60. BIN
      mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
  61. 23 0
      mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
  62. BIN
      mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
  63. BIN
      mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
  64. BIN
      mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
  65. 5 0
      mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
  66. 37 0
      mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
  67. 26 0
      mobile/ios/Runner/Base.lproj/Main.storyboard
  68. 77 0
      mobile/ios/Runner/Info.plist
  69. 1 0
      mobile/ios/Runner/Runner-Bridging-Header.h
  70. 6 0
      mobile/ios/Runner/SceneDelegate.swift
  71. 12 0
      mobile/ios/RunnerTests/RunnerTests.swift
  72. 108 0
      mobile/lib/data/api_client.dart
  73. 30 0
      mobile/lib/data/payload.dart
  74. 31 0
      mobile/lib/data/settings_store.dart
  75. 435 0
      mobile/lib/design.dart
  76. 68 0
      mobile/lib/main.dart
  77. 333 0
      mobile/lib/models/inventory.dart
  78. 170 0
      mobile/lib/screens/add_resource_screen.dart
  79. 134 0
      mobile/lib/screens/connection_screen.dart
  80. 544 0
      mobile/lib/screens/edit_resource_screen.dart
  81. 168 0
      mobile/lib/screens/home_shell.dart
  82. 79 0
      mobile/lib/screens/privacy_policy_screen.dart
  83. 234 0
      mobile/lib/screens/resource_detail_screen.dart
  84. 161 0
      mobile/lib/screens/resources_tab.dart
  85. 125 0
      mobile/lib/screens/summary_tab.dart
  86. 117 0
      mobile/lib/screens/tree_tab.dart
  87. 67 0
      mobile/lib/state/inventory_store.dart
  88. 48 0
      mobile/privacy-policy.md
  89. 498 0
      mobile/pubspec.lock
  90. 91 0
      mobile/pubspec.yaml
  91. BIN
      mobile/store-assets.zip
  92. 41 0
      mobile/store-assets/README.md
  93. BIN
      mobile/store-assets/ios/app-icon-1024.png
  94. BIN
      mobile/store-assets/ios/creative-assets/header-3840x1646.jpg
  95. BIN
      mobile/store-assets/ios/creative-assets/universal-5244x2950.png
  96. BIN
      mobile/store-assets/ios/creative-assets/working/brand-background.png
  97. 23 0
      mobile/store-assets/ios/creative-assets/working/header.svg
  98. 23 0
      mobile/store-assets/ios/creative-assets/working/universal.svg
  99. BIN
      mobile/store-assets/ios/ipad-13/01-overview.jpg
  100. BIN
      mobile/store-assets/ios/ipad-13/02-inventory.jpg

+ 3 - 0
.gitignore

@@ -428,3 +428,6 @@ FodyWeavers.xsd
 # macOS
 .DS_Store
 ISSUE_TRIAGE.md
+
+# Local working notes, credentials, and deployment configuration
+/temp/

+ 76 - 0
RackPeek.Domain/Api/InventoryDocumentJson.cs

@@ -0,0 +1,76 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.UpsUnits;
+
+namespace RackPeek.Domain.Api;
+
+/// <summary>
+///     Reads the inventory as a JSON document in the same shape <c>POST /api/inventory</c>
+///     accepts: camelCase with a per-resource "kind" discriminator. The discriminator is
+///     written from the CLR type, so what this produces can be fed straight back to the
+///     import endpoint — including the AccessPoint/Accesspoint casing difference that
+///     trips hand-written documents.
+/// </summary>
+public static class InventoryDocumentJson {
+    public static readonly JsonSerializerOptions Options = new() {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        ReferenceHandler = ReferenceHandler.IgnoreCycles
+    };
+
+    public static string KindLabel(Resource resource) => resource switch {
+        Server => Server.KindLabel,
+        Switch => Switch.KindLabel,
+        Firewall => Firewall.KindLabel,
+        Router => Router.KindLabel,
+        Desktop => Desktop.KindLabel,
+        Laptop => Laptop.KindLabel,
+        AccessPoint => AccessPoint.KindLabel,
+        Ups => Ups.KindLabel,
+        Other => Other.KindLabel,
+        SystemResource => SystemResource.KindLabel,
+        Service => Service.KindLabel,
+        _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
+    };
+
+    public static JsonNode ToNode(Resource resource) {
+        // Serialize against the concrete type: no polymorphism resolver, so the
+        // Resource.Kind property does not collide with the discriminator name.
+        // The discriminator must come first — the import endpoint's reader cannot
+        // buffer derived-type fields (ports, network, …) until the type is resolved.
+        JsonObject body = JsonSerializer.SerializeToNode(resource, resource.GetType(), Options)!.AsObject();
+        body.Remove("kind");
+
+        var node = new JsonObject { ["kind"] = KindLabel(resource) };
+        foreach (KeyValuePair<string, JsonNode?> property in body)
+            node[property.Key] = property.Value?.DeepClone();
+
+        return node;
+    }
+
+    public static async Task<JsonObject> BuildDocumentAsync(IResourceCollection repo) {
+        IReadOnlyList<Resource> resources = await repo.GetAllOfTypeAsync<Resource>();
+        IReadOnlyList<Connection> connections = await repo.GetConnectionsAsync();
+
+        return new JsonObject {
+            ["version"] = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+            ["resources"] = new JsonArray([.. resources.Select(ToNode)]),
+            ["connections"] = new JsonArray([.. connections.Select(c => JsonSerializer.SerializeToNode(c, Options))])
+        };
+    }
+}

+ 7 - 0
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -53,6 +53,13 @@ public class UpsertInventoryUseCase(
                                _jsonOptions)
                            ?? throw new ValidationException("Invalid JSON structure.");
 
+            // The JSON "kind" is consumed as the polymorphism discriminator, so the
+            // Kind property stays empty. Stamp it from the resolved type — the same
+            // label the discriminator carried — so diff snapshots and the re-serialized
+            // YAML match what a YAML upload would produce.
+            foreach (Resource resource in incomingRoot.Resources ?? [])
+                resource.Kind = InventoryDocumentJson.KindLabel(resource);
+
             yamlInput = YamlResourceCollection.SerializeRootAsync(incomingRoot);
         }
 

+ 83 - 0
RackPeek.Web/Api/InventoryReadEndpoints.cs

@@ -0,0 +1,83 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services.UseCases;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+using RackPeek.Domain.Search;
+using RackPeek.Mcp.Tools;
+
+namespace RackPeek.Web.Api;
+
+/// <summary>
+///     Read-only counterparts to POST /api/inventory, gated by the same API key.
+///     The full document endpoint is the workhorse: a client fetches it once and
+///     renders lists, details and trees from it. Summary, search and tree mirror the
+///     MCP query tools and reuse their response records so the two surfaces cannot drift.
+/// </summary>
+public static class InventoryReadEndpoints {
+    public static void MapInventoryReadApi(this WebApplication app) {
+        RouteGroupBuilder group = app.MapGroup("/api/inventory")
+            .AddEndpointFilter<ApiKeyEndpointFilter>()
+            .DisableAntiforgery();
+
+        group.MapGet("", async (IResourceCollection repo) => {
+            try {
+                return Results.Json(await InventoryDocumentJson.BuildDocumentAsync(repo),
+                    InventoryDocumentJson.Options);
+            }
+            catch (Exception ex) {
+                return ReadError(ex);
+            }
+        });
+
+        group.MapGet("summary", async (
+            IResourceCollection repo,
+            GetHardwareUseCaseSummary hardwareSummary,
+            GetSystemSummaryUseCase systemsSummary,
+            GetServiceSummaryUseCase servicesSummary) => {
+                try {
+                    var result = new InfrastructureSummary(
+                        await hardwareSummary.ExecuteAsync(),
+                        await systemsSummary.ExecuteAsync(),
+                        await servicesSummary.ExecuteAsync(),
+                        await repo.GetTagsAsync(),
+                        await repo.GetLabelsAsync());
+
+                    return Results.Ok(result);
+                }
+                catch (Exception ex) {
+                    return ReadError(ex);
+                }
+            });
+
+        group.MapGet("search", async (string q, int? max, IResourceCollection repo) => {
+            try {
+                IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
+
+                return Results.Ok(new SearchResults(
+                    GlobalSearchService.Search(all, q, max ?? 8)));
+            }
+            catch (Exception ex) {
+                return ReadError(ex);
+            }
+        });
+
+        group.MapGet("tree", async (IHardwareRepository repo) => {
+            try {
+                return Results.Ok(new TreeResult(await repo.GetTreeAsync()));
+            }
+            catch (Exception ex) {
+                return ReadError(ex);
+            }
+        });
+    }
+
+    private static IResult ReadError(Exception ex) => ex switch {
+        NotFoundException => Results.NotFound(new { error = ex.Message }),
+        ValidationException => Results.BadRequest(new { error = ex.Message }),
+        _ => Results.Problem(ex.Message)
+    };
+}

+ 1 - 0
RackPeek.Web/Program.cs

@@ -138,6 +138,7 @@ public class Program {
         app.UseAntiforgery();
 
         app.MapInventoryApi();
+        app.MapInventoryReadApi();
 
         // Same key, same gate as /api/inventory: 503 until RPK_API_KEY is configured,
         // so MCP is off by default and never exposes the inventory unauthenticated.

+ 22 - 0
Shared.Rcl/wwwroot/raw_docs/inventory-api.md

@@ -142,6 +142,28 @@ resource type: `Server`, `Switch`, `Router`, `Firewall`, `AccessPoint`,
 | `401 Unauthorized` | `X-Api-Key` header missing or doesn't match `RPK_API_KEY`. |
 | `503 Service Unavailable` | `RPK_API_KEY` is not configured on the server. |
 
+### Read endpoints
+
+All read endpoints require the same `X-Api-Key` header and return
+`401`/`503` exactly as above.
+
+| Endpoint | Returns |
+|---|---|
+| `GET /api/inventory` | The full inventory as a JSON document: `{ "version", "resources", "connections" }`, same shape the `json` field above accepts. Fetch once, render lists/details/trees client-side. |
+| `GET /api/inventory/summary` | Counts: `hardware` (total + by kind), `systems` (total + by type + by OS), `services` (total + IP addresses), `tags`, `labels`. |
+| `GET /api/inventory/search?q=<text>&max=<n>` | Ranked matches over names, IPs, tags and labels: `{ "matches": [{ "name", "kind", "url", "matchedField", "matchedValue", "score" }] }`. `max` defaults to 8. |
+| `GET /api/inventory/tree` | The containment forest: `{ "hardware": [{ "hardwareName", "kind", "systems": [{ "systemName", "services" }] }] }`. |
+
+The document from `GET /api/inventory` round-trips: send it back to
+`POST /api/inventory` as the `json` field (optionally with `dryRun: true`)
+and the server reports no changes. Every resource carries the correct
+case-sensitive `kind` discriminator, so clients never have to know the
+`AccessPoint`/`Accesspoint` spelling difference.
+
+```bash
+curl -s -H "X-Api-Key: $RPK_API_KEY" http://localhost:8080/api/inventory | jq '.resources | length'
+```
+
 ## Examples
 
 ### Upsert from a YAML file with curl

+ 210 - 0
Tests/Api/InventoryReadEndpointTests.cs

@@ -0,0 +1,210 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using RackPeek.Domain.Api;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+[Collection("Process-wide static state")]
+public class InventoryReadEndpointTests(ITestOutputHelper output) : ApiTestBase(output) {
+    private const string _seedYaml = """
+                                    resources:
+                                      - kind: Server
+                                        name: srv-read
+                                        tags:
+                                          - homelab
+                                        ports:
+                                          - type: rj45
+                                            speed: 1
+                                            count: 2
+                                      - kind: Switch
+                                        name: sw-read
+                                        ports:
+                                          - type: rj45
+                                            speed: 1
+                                            count: 4
+                                      - kind: AccessPoint
+                                        name: ap-read
+                                        model: U6 Lite
+                                      - kind: System
+                                        name: pve-read
+                                        runsOn:
+                                          - srv-read
+                                        type: hypervisor
+                                        os: Proxmox VE 9
+                                      - kind: Service
+                                        name: svc-read
+                                        runsOn:
+                                          - pve-read
+                                        network:
+                                          ip: 10.0.0.5
+                                          port: 8080
+                                    connections:
+                                      - a:
+                                          resource: srv-read
+                                          portGroup: 0
+                                          portIndex: 0
+                                        b:
+                                          resource: sw-read
+                                          portGroup: 0
+                                          portIndex: 0
+                                    """;
+
+    private async Task SeedAsync(HttpClient client) {
+        HttpResponseMessage seed = await client.PostAsJsonAsync("/api/inventory", new { yaml = _seedYaml });
+        Assert.True(seed.StatusCode == HttpStatusCode.OK,
+            $"seed failed: {await seed.Content.ReadAsStringAsync()}");
+    }
+
+    [Fact]
+    public async Task Get_Inventory_Without_Api_Key_Returns_401() {
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.GetAsync("/api/inventory");
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Get_Summary_Without_Api_Key_Returns_401() {
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.GetAsync("/api/inventory/summary");
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Get_Inventory_Returns_Full_Document() {
+        HttpClient client = CreateClient(true);
+        await SeedAsync(client);
+
+        HttpResponseMessage response = await client.GetAsync("/api/inventory");
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        using JsonDocument doc = await ReadJsonAsync(response);
+        JsonElement root = doc.RootElement;
+
+        Assert.Equal(RackPeek.Domain.Persistence.Yaml.RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+            root.GetProperty("version").GetInt32());
+        Assert.Equal(5, root.GetProperty("resources").GetArrayLength());
+        Assert.Equal(1, root.GetProperty("connections").GetArrayLength());
+
+        JsonElement server = root.GetProperty("resources").EnumerateArray()
+            .First(r => r.GetProperty("name").GetString() == "srv-read");
+        Assert.Equal("Server", server.GetProperty("kind").GetString());
+        Assert.Contains("homelab", server.GetProperty("tags").EnumerateArray()
+            .Select(t => t.GetString()));
+        // camelCase field names, matching the published schema
+        Assert.Equal("rj45", server.GetProperty("ports")[0].GetProperty("type").GetString());
+
+        JsonElement connection = root.GetProperty("connections")[0];
+        Assert.Equal("srv-read", connection.GetProperty("a").GetProperty("resource").GetString());
+        Assert.Equal("sw-read", connection.GetProperty("b").GetProperty("resource").GetString());
+    }
+
+    [Fact]
+    public async Task Get_Inventory_Emits_Json_Discriminator_Casing() {
+        // The Resource.Kind property and the URL/routing kind say "Accesspoint";
+        // the JSON discriminator the import endpoint requires is "AccessPoint".
+        // The document must always carry the importable spelling.
+        HttpClient client = CreateClient(true);
+        await SeedAsync(client);
+
+        using JsonDocument doc = await ReadJsonAsync(await client.GetAsync("/api/inventory"));
+
+        JsonElement ap = doc.RootElement.GetProperty("resources").EnumerateArray()
+            .First(r => r.GetProperty("name").GetString() == "ap-read");
+
+        Assert.Equal("AccessPoint", ap.GetProperty("kind").GetString());
+    }
+
+    [Fact]
+    public async Task Get_Inventory_Document_Round_Trips_Through_Import_As_No_Op() {
+        // The whole point of the read API for a companion app: fetch the document,
+        // send it back unchanged, and the server reports nothing changed.
+        HttpClient client = CreateClient(true);
+        await SeedAsync(client);
+
+        using JsonDocument doc = await ReadJsonAsync(await client.GetAsync("/api/inventory"));
+        JsonElement document = JsonDocument.Parse(doc.RootElement.GetRawText()).RootElement;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new {
+                json = document,
+                dryRun = true
+            });
+
+        Assert.True(response.StatusCode == HttpStatusCode.OK,
+            $"round trip failed: {await response.Content.ReadAsStringAsync()}");
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Empty(result!.Added);
+        Assert.Empty(result.Updated);
+        Assert.Empty(result.Replaced);
+        Assert.Empty(result.ConnectionsAdded);
+        Assert.Empty(result.ConnectionsRemoved);
+    }
+
+    [Fact]
+    public async Task Get_Summary_Returns_Counts() {
+        HttpClient client = CreateClient(true);
+        await SeedAsync(client);
+
+        using JsonDocument doc = await ReadJsonAsync(await client.GetAsync("/api/inventory/summary"));
+        JsonElement root = doc.RootElement;
+
+        Assert.Equal(3, root.GetProperty("hardware").GetProperty("totalHardware").GetInt32());
+        Assert.Equal(1, root.GetProperty("systems").GetProperty("totalSystems").GetInt32());
+        Assert.Equal(1, root.GetProperty("services").GetProperty("totalServices").GetInt32());
+        Assert.Equal(1, root.GetProperty("tags").GetProperty("homelab").GetInt32());
+    }
+
+    [Fact]
+    public async Task Get_Search_Returns_Ranked_Matches() {
+        HttpClient client = CreateClient(true);
+        await SeedAsync(client);
+
+        using JsonDocument doc =
+            await ReadJsonAsync(await client.GetAsync("/api/inventory/search?q=svc-read"));
+        JsonElement matches = doc.RootElement.GetProperty("matches");
+
+        Assert.True(matches.GetArrayLength() >= 1);
+        Assert.Equal("svc-read", matches[0].GetProperty("name").GetString());
+        Assert.Equal("Service", matches[0].GetProperty("kind").GetString());
+    }
+
+    [Fact]
+    public async Task Get_Search_With_Missing_Query_Returns_400() {
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.GetAsync("/api/inventory/search");
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Get_Tree_Returns_Containment_Forest() {
+        HttpClient client = CreateClient(true);
+        await SeedAsync(client);
+
+        using JsonDocument doc = await ReadJsonAsync(await client.GetAsync("/api/inventory/tree"));
+        JsonElement hardware = doc.RootElement.GetProperty("hardware");
+
+        JsonElement server = hardware.EnumerateArray()
+            .First(h => h.GetProperty("hardwareName").GetString() == "srv-read");
+
+        Assert.Equal("Server", server.GetProperty("kind").GetString());
+        Assert.Single(server.GetProperty("systems").EnumerateArray());
+        Assert.Contains("svc-read",
+            server.GetProperty("systems")[0].GetProperty("services").EnumerateArray()
+                .Select(s => s.GetString()));
+    }
+
+    private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response) {
+        var body = await response.Content.ReadAsStringAsync();
+        return JsonDocument.Parse(body);
+    }
+}

+ 45 - 0
mobile/.gitignore

@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release

+ 33 - 0
mobile/.metadata

@@ -0,0 +1,33 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: "48c32af0345e9ad5747f78ddce828c7f795f7159"
+  channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+  platforms:
+    - platform: root
+      create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+      base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+    - platform: android
+      create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+      base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+    - platform: ios
+      create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+      base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+
+  # User provided section
+
+  # List of Local paths (relative to this file) that should be
+  # ignored by the migrate tool.
+  #
+  # Files that are not part of the templates will be ignored by default.
+  unmanaged_files:
+    - 'lib/main.dart'
+    - 'ios/Runner.xcodeproj/project.pbxproj'

+ 24 - 0
mobile/README.md

@@ -0,0 +1,24 @@
+# Mobile app
+
+The Flutter app connects to a RackPeek server and lets people view, search,
+create, and edit infrastructure inventory from a mobile device.
+
+## Local development
+
+Install Flutter and Xcode, then run:
+
+```sh
+flutter pub get
+flutter run
+```
+
+## Privacy
+
+The app's privacy policy is in [`privacy-policy.md`](privacy-policy.md). Review
+it against the behavior of each release before publishing.
+
+## Store assets
+
+App icons, screenshots, and product-page creative are in
+[`store-assets/`](store-assets/). The folder README describes the included
+assets.

+ 28 - 0
mobile/analysis_options.yaml

@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+  # The lint rules applied to this project can be customized in the
+  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+  # included above or to enable additional rules. A list of all available lints
+  # and their documentation is published at https://dart.dev/lints.
+  #
+  # Instead of disabling a lint rule for the entire project in the
+  # section below, it can also be suppressed for a single line of code
+  # or a specific dart file by using the `// ignore: name_of_lint` and
+  # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+  # producing the lint.
+  rules:
+    # avoid_print: false  # Uncomment to disable the `avoid_print` rule
+    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options

+ 14 - 0
mobile/android/.gitignore

@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks

+ 44 - 0
mobile/android/app/build.gradle.kts

@@ -0,0 +1,44 @@
+plugins {
+    id("com.android.application")
+    id("kotlin-android")
+    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+    id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+    namespace = "com.rackpeek.rackpeek_mobile"
+    compileSdk = flutter.compileSdkVersion
+    ndkVersion = flutter.ndkVersion
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+    }
+
+    defaultConfig {
+        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+        applicationId = "com.rackpeek.rackpeek_mobile"
+        // You can update the following values to match your application needs.
+        // For more information, see: https://flutter.dev/to/review-gradle-config.
+        minSdk = flutter.minSdkVersion
+        targetSdk = flutter.targetSdkVersion
+        versionCode = flutter.versionCode
+        versionName = flutter.versionName
+    }
+
+    buildTypes {
+        release {
+            // TODO: Add your own signing config for the release build.
+            // Signing with the debug keys for now, so `flutter run --release` works.
+            signingConfig = signingConfigs.getByName("debug")
+        }
+    }
+}
+
+flutter {
+    source = "../.."
+}

+ 45 - 0
mobile/android/app/src/main/AndroidManifest.xml

@@ -0,0 +1,45 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application
+        android:label="rackpeek_mobile"
+        android:name="${applicationName}"
+        android:icon="@mipmap/ic_launcher">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:taskAffinity=""
+            android:theme="@style/LaunchTheme"
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+            android:hardwareAccelerated="true"
+            android:windowSoftInputMode="adjustResize">
+            <!-- Specifies an Android theme to apply to this Activity as soon as
+                 the Android process has started. This theme is visible to the user
+                 while the Flutter UI initializes. After that, this theme continues
+                 to determine the Window background behind the Flutter UI. -->
+            <meta-data
+              android:name="io.flutter.embedding.android.NormalTheme"
+              android:resource="@style/NormalTheme"
+              />
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </activity>
+        <!-- Don't delete the meta-data below.
+             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+        <meta-data
+            android:name="flutterEmbedding"
+            android:value="2" />
+    </application>
+    <!-- Required to query activities that can process text, see:
+         https://developer.android.com/training/package-visibility and
+         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
+
+         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
+    <queries>
+        <intent>
+            <action android:name="android.intent.action.PROCESS_TEXT"/>
+            <data android:mimeType="text/plain"/>
+        </intent>
+    </queries>
+</manifest>

+ 5 - 0
mobile/android/app/src/main/kotlin/com/rackpeek/rackpeek_mobile/MainActivity.kt

@@ -0,0 +1,5 @@
+package com.rackpeek.rackpeek_mobile
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()

+ 12 - 0
mobile/android/app/src/main/res/drawable-v21/launch_background.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="?android:colorBackground" />
+
+    <!-- You can insert your own image assets here -->
+    <!-- <item>
+        <bitmap
+            android:gravity="center"
+            android:src="@mipmap/launch_image" />
+    </item> -->
+</layer-list>

+ 12 - 0
mobile/android/app/src/main/res/drawable/launch_background.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@android:color/white" />
+
+    <!-- You can insert your own image assets here -->
+    <!-- <item>
+        <bitmap
+            android:gravity="center"
+            android:src="@mipmap/launch_image" />
+    </item> -->
+</layer-list>

BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


+ 18 - 0
mobile/android/app/src/main/res/values-night/styles.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
+    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <!-- Show a splash screen on the activity. Automatically removed when
+             the Flutter engine draws its first frame -->
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <!-- Theme applied to the Android Window as soon as the process has started.
+         This theme determines the color of the Android Window while your
+         Flutter UI initializes, as well as behind your Flutter UI while its
+         running.
+
+         This Theme is only used starting with V2 of Flutter's Android embedding. -->
+    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">?android:colorBackground</item>
+    </style>
+</resources>

+ 18 - 0
mobile/android/app/src/main/res/values/styles.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
+    <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
+        <!-- Show a splash screen on the activity. Automatically removed when
+             the Flutter engine draws its first frame -->
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <!-- Theme applied to the Android Window as soon as the process has started.
+         This theme determines the color of the Android Window while your
+         Flutter UI initializes, as well as behind your Flutter UI while its
+         running.
+
+         This Theme is only used starting with V2 of Flutter's Android embedding. -->
+    <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
+        <item name="android:windowBackground">?android:colorBackground</item>
+    </style>
+</resources>

+ 7 - 0
mobile/android/app/src/profile/AndroidManifest.xml

@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- The INTERNET permission is required for development. Specifically,
+         the Flutter tool needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>

+ 24 - 0
mobile/android/build.gradle.kts

@@ -0,0 +1,24 @@
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+val newBuildDir: Directory =
+    rootProject.layout.buildDirectory
+        .dir("../../build")
+        .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+    val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+    project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+    project.evaluationDependsOn(":app")
+}
+
+tasks.register<Delete>("clean") {
+    delete(rootProject.layout.buildDirectory)
+}

+ 2 - 0
mobile/android/gradle.properties

@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true

+ 5 - 0
mobile/android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

+ 26 - 0
mobile/android/settings.gradle.kts

@@ -0,0 +1,26 @@
+pluginManagement {
+    val flutterSdkPath =
+        run {
+            val properties = java.util.Properties()
+            file("local.properties").inputStream().use { properties.load(it) }
+            val flutterSdkPath = properties.getProperty("flutter.sdk")
+            require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+            flutterSdkPath
+        }
+
+    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+plugins {
+    id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+    id("com.android.application") version "8.11.1" apply false
+    id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")

+ 34 - 0
mobile/ios/.gitignore

@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3

+ 24 - 0
mobile/ios/Flutter/AppFrameworkInfo.plist

@@ -0,0 +1,24 @@
+<?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>CFBundleDevelopmentRegion</key>
+  <string>en</string>
+  <key>CFBundleExecutable</key>
+  <string>App</string>
+  <key>CFBundleIdentifier</key>
+  <string>io.flutter.flutter.app</string>
+  <key>CFBundleInfoDictionaryVersion</key>
+  <string>6.0</string>
+  <key>CFBundleName</key>
+  <string>App</string>
+  <key>CFBundlePackageType</key>
+  <string>FMWK</string>
+  <key>CFBundleShortVersionString</key>
+  <string>1.0</string>
+  <key>CFBundleSignature</key>
+  <string>????</string>
+  <key>CFBundleVersion</key>
+  <string>1.0</string>
+</dict>
+</plist>

+ 2 - 0
mobile/ios/Flutter/Debug.xcconfig

@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"

+ 2 - 0
mobile/ios/Flutter/Release.xcconfig

@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"

+ 42 - 0
mobile/ios/Podfile

@@ -0,0 +1,42 @@
+platform :ios, '15.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+  'Debug' => :debug,
+  'Profile' => :release,
+  'Release' => :release,
+}
+
+def flutter_root
+  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+  unless File.exist?(generated_xcode_build_settings_path)
+    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+  end
+
+  File.foreach(generated_xcode_build_settings_path) do |line|
+    matches = line.match(/FLUTTER_ROOT\=(.*)/)
+    return matches[1].strip if matches
+  end
+  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+  use_frameworks!
+
+  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+  target 'RunnerTests' do
+    inherit! :search_paths
+  end
+end
+
+post_install do |installer|
+  installer.pods_project.targets.each do |target|
+    flutter_additional_ios_build_settings(target)
+  end
+end

+ 23 - 0
mobile/ios/Podfile.lock

@@ -0,0 +1,23 @@
+PODS:
+  - Flutter (1.0.0)
+  - flutter_secure_storage_darwin (10.0.0):
+    - Flutter
+    - FlutterMacOS
+
+DEPENDENCIES:
+  - Flutter (from `Flutter`)
+  - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
+
+EXTERNAL SOURCES:
+  Flutter:
+    :path: Flutter
+  flutter_secure_storage_darwin:
+    :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
+
+SPEC CHECKSUMS:
+  Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+  flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58
+
+PODFILE CHECKSUM: ce2a4dd764e1c7aeed6a7cdc5e61d092b6dc6d32
+
+COCOAPODS: 1.16.2

+ 732 - 0
mobile/ios/Runner.xcodeproj/project.pbxproj

@@ -0,0 +1,732 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 54;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+		22D4BC0AC579F0EA7CE96B82 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C0970462C5EBA081FFE55BE /* Pods_Runner.framework */; };
+		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+		74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+		7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+		C3E59B4040C2B0BBCBB79DCC /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 11DAC6C7B600670BF8804BD2 /* Pods_RunnerTests.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+			remoteInfo = Runner;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+		9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Embed Frameworks";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+		06B19A2E97C78CD9FCF897CB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
+		11DAC6C7B600670BF8804BD2 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
+		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
+		1DBA95B42516C13988165576 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
+		30742302D397F90293FAD6ED /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
+		331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
+		331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
+		4C0970462C5EBA081FFE55BE /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
+		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
+		7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
+		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
+		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
+		9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
+		97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
+		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
+		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		A4C538579DFFC4915AB1407A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
+		DB1145173C8C21EB15D289EC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
+		E458B97E03CF36E54E34A4E7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		266C0F4D2DD5A6E4C3E00741 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C3E59B4040C2B0BBCBB79DCC /* Pods_RunnerTests.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		97C146EB1CF9000F007C117D /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				22D4BC0AC579F0EA7CE96B82 /* Pods_Runner.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		331C8082294A63A400263BE5 /* RunnerTests */ = {
+			isa = PBXGroup;
+			children = (
+				331C807B294A618700263BE5 /* RunnerTests.swift */,
+			);
+			path = RunnerTests;
+			sourceTree = "<group>";
+		};
+		8E8FB2F610C4D32097A5741C /* Pods */ = {
+			isa = PBXGroup;
+			children = (
+				A4C538579DFFC4915AB1407A /* Pods-Runner.debug.xcconfig */,
+				1DBA95B42516C13988165576 /* Pods-Runner.release.xcconfig */,
+				DB1145173C8C21EB15D289EC /* Pods-Runner.profile.xcconfig */,
+				30742302D397F90293FAD6ED /* Pods-RunnerTests.debug.xcconfig */,
+				E458B97E03CF36E54E34A4E7 /* Pods-RunnerTests.release.xcconfig */,
+				06B19A2E97C78CD9FCF897CB /* Pods-RunnerTests.profile.xcconfig */,
+			);
+			name = Pods;
+			path = Pods;
+			sourceTree = "<group>";
+		};
+		911BEED443BFEF00849ADE5A /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				4C0970462C5EBA081FFE55BE /* Pods_Runner.framework */,
+				11DAC6C7B600670BF8804BD2 /* Pods_RunnerTests.framework */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		9740EEB11CF90186004384FC /* Flutter */ = {
+			isa = PBXGroup;
+			children = (
+				3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+				9740EEB21CF90195004384FC /* Debug.xcconfig */,
+				7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+				9740EEB31CF90195004384FC /* Generated.xcconfig */,
+			);
+			name = Flutter;
+			sourceTree = "<group>";
+		};
+		97C146E51CF9000F007C117D = {
+			isa = PBXGroup;
+			children = (
+				9740EEB11CF90186004384FC /* Flutter */,
+				97C146F01CF9000F007C117D /* Runner */,
+				97C146EF1CF9000F007C117D /* Products */,
+				331C8082294A63A400263BE5 /* RunnerTests */,
+				8E8FB2F610C4D32097A5741C /* Pods */,
+				911BEED443BFEF00849ADE5A /* Frameworks */,
+			);
+			sourceTree = "<group>";
+		};
+		97C146EF1CF9000F007C117D /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				97C146EE1CF9000F007C117D /* Runner.app */,
+				331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		97C146F01CF9000F007C117D /* Runner */ = {
+			isa = PBXGroup;
+			children = (
+				97C146FA1CF9000F007C117D /* Main.storyboard */,
+				97C146FD1CF9000F007C117D /* Assets.xcassets */,
+				97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+				97C147021CF9000F007C117D /* Info.plist */,
+				1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+				1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+				74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+				7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+				74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+			);
+			path = Runner;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		331C8080294A63A400263BE5 /* RunnerTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+			buildPhases = (
+				03B9ED101DFDCAB57843648B /* [CP] Check Pods Manifest.lock */,
+				331C807D294A63A400263BE5 /* Sources */,
+				331C807F294A63A400263BE5 /* Resources */,
+				266C0F4D2DD5A6E4C3E00741 /* Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				331C8086294A63A400263BE5 /* PBXTargetDependency */,
+			);
+			name = RunnerTests;
+			productName = RunnerTests;
+			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+		97C146ED1CF9000F007C117D /* Runner */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+			buildPhases = (
+				F864BF3E13259CE955677E76 /* [CP] Check Pods Manifest.lock */,
+				9740EEB61CF901F6004384FC /* Run Script */,
+				97C146EA1CF9000F007C117D /* Sources */,
+				97C146EB1CF9000F007C117D /* Frameworks */,
+				97C146EC1CF9000F007C117D /* Resources */,
+				9705A1C41CF9048500538489 /* Embed Frameworks */,
+				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+				850C36EF4765D7DA3FBC1066 /* [CP] Embed Pods Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = Runner;
+			productName = Runner;
+			productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		97C146E61CF9000F007C117D /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = YES;
+				LastUpgradeCheck = 1510;
+				ORGANIZATIONNAME = "";
+				TargetAttributes = {
+					331C8080294A63A400263BE5 = {
+						CreatedOnToolsVersion = 14.0;
+						TestTargetID = 97C146ED1CF9000F007C117D;
+					};
+					97C146ED1CF9000F007C117D = {
+						CreatedOnToolsVersion = 7.3.1;
+						LastSwiftMigration = 1100;
+					};
+				};
+			};
+			buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+			compatibilityVersion = "Xcode 9.3";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = 97C146E51CF9000F007C117D;
+			productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				97C146ED1CF9000F007C117D /* Runner */,
+				331C8080294A63A400263BE5 /* RunnerTests */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		331C807F294A63A400263BE5 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		97C146EC1CF9000F007C117D /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+				3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+				97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+				97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		03B9ED101DFDCAB57843648B /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+			);
+			name = "Thin Binary";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+		};
+		850C36EF4765D7DA3FBC1066 /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		9740EEB61CF901F6004384FC /* Run Script */ = {
+			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+			);
+			name = "Run Script";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+		};
+		F864BF3E13259CE955677E76 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		331C807D294A63A400263BE5 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		97C146EA1CF9000F007C117D /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+				1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+				7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 97C146ED1CF9000F007C117D /* Runner */;
+			targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+		97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+			isa = PBXVariantGroup;
+			children = (
+				97C146FB1CF9000F007C117D /* Base */,
+			);
+			name = Main.storyboard;
+			sourceTree = "<group>";
+		};
+		97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+			isa = PBXVariantGroup;
+			children = (
+				97C147001CF9000F007C117D /* Base */,
+			);
+			name = LaunchScreen.storyboard;
+			sourceTree = "<group>";
+		};
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+		249021D3217E4FDB00AE95B9 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				SDKROOT = iphoneos;
+				SUPPORTED_PLATFORMS = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Profile;
+		};
+		249021D4217E4FDB00AE95B9 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+				ENABLE_BITCODE = NO;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.rackpeek.rackpeekMobile;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+				SWIFT_VERSION = 5.0;
+				VERSIONING_SYSTEM = "apple-generic";
+			};
+			name = Profile;
+		};
+		331C8088294A63A400263BE5 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 30742302D397F90293FAD6ED /* Pods-RunnerTests.debug.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.rackpeek.rackpeekMobile.RunnerTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+			};
+			name = Debug;
+		};
+		331C8089294A63A400263BE5 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = E458B97E03CF36E54E34A4E7 /* Pods-RunnerTests.release.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.rackpeek.rackpeekMobile.RunnerTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+			};
+			name = Release;
+		};
+		331C808A294A63A400263BE5 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 06B19A2E97C78CD9FCF897CB /* Pods-RunnerTests.profile.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.rackpeek.rackpeekMobile.RunnerTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+			};
+			name = Profile;
+		};
+		97C147031CF9000F007C117D /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				MTL_ENABLE_DEBUG_INFO = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		97C147041CF9000F007C117D /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				SDKROOT = iphoneos;
+				SUPPORTED_PLATFORMS = iphoneos;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		97C147061CF9000F007C117D /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+				ENABLE_BITCODE = NO;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.rackpeek.rackpeekMobile;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.0;
+				VERSIONING_SYSTEM = "apple-generic";
+			};
+			name = Debug;
+		};
+		97C147071CF9000F007C117D /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+				ENABLE_BITCODE = NO;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.rackpeek.rackpeekMobile;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+				SWIFT_VERSION = 5.0;
+				VERSIONING_SYSTEM = "apple-generic";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				331C8088294A63A400263BE5 /* Debug */,
+				331C8089294A63A400263BE5 /* Release */,
+				331C808A294A63A400263BE5 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				97C147031CF9000F007C117D /* Debug */,
+				97C147041CF9000F007C117D /* Release */,
+				249021D3217E4FDB00AE95B9 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				97C147061CF9000F007C117D /* Debug */,
+				97C147071CF9000F007C117D /* Release */,
+				249021D4217E4FDB00AE95B9 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}

+ 7 - 0
mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "self:">
+   </FileRef>
+</Workspace>

+ 8 - 0
mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?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>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 8 - 0
mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings

@@ -0,0 +1,8 @@
+<?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>PreviewsEnabled</key>
+	<false/>
+</dict>
+</plist>

+ 101 - 0
mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1510"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+               BuildableName = "Runner.app"
+               BlueprintName = "Runner"
+               ReferencedContainer = "container:Runner.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+            BuildableName = "Runner.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "331C8080294A63A400263BE5"
+               BuildableName = "RunnerTests.xctest"
+               BlueprintName = "RunnerTests"
+               ReferencedContainer = "container:Runner.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      enableGPUValidationMode = "1"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+            BuildableName = "Runner.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Profile"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+            BuildableName = "Runner.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 10 - 0
mobile/ios/Runner.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "group:Runner.xcodeproj">
+   </FileRef>
+   <FileRef
+      location = "group:Pods/Pods.xcodeproj">
+   </FileRef>
+</Workspace>

+ 8 - 0
mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?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>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 8 - 0
mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings

@@ -0,0 +1,8 @@
+<?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>PreviewsEnabled</key>
+	<false/>
+</dict>
+</plist>

+ 16 - 0
mobile/ios/Runner/AppDelegate.swift

@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+  override func application(
+    _ application: UIApplication,
+    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+  ) -> Bool {
+    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+  }
+
+  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+  }
+}

+ 122 - 0
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,122 @@
+{
+  "images" : [
+    {
+      "size" : "20x20",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-20x20@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "20x20",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-20x20@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-29x29@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-29x29@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-29x29@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-40x40@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-40x40@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "60x60",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-60x60@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "60x60",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-60x60@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "20x20",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-20x20@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "20x20",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-20x20@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-29x29@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-29x29@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-40x40@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-40x40@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "76x76",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-76x76@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "76x76",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-76x76@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "83.5x83.5",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-83.5x83.5@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "1024x1024",
+      "idiom" : "ios-marketing",
+      "filename" : "Icon-App-1024x1024@1x.png",
+      "scale" : "1x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png


BIN
mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png


+ 23 - 0
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json

@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "filename" : "LaunchImage.png",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "LaunchImage@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "LaunchImage@3x.png",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

BIN
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png


BIN
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png


BIN
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png


+ 5 - 0
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md

@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

+ 37 - 0
mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="EHf-IW-A2E">
+            <objects>
+                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
+                        <viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <subviews>
+                            <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
+                            </imageView>
+                        </subviews>
+                        <color key="backgroundColor" red="0.949" green="0.949" blue="0.969" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+                        <constraints>
+                            <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
+                            <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
+                        </constraints>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="53" y="375"/>
+        </scene>
+    </scenes>
+    <resources>
+        <image name="LaunchImage" width="168" height="185"/>
+    </resources>
+</document>

+ 26 - 0
mobile/ios/Runner/Base.lproj/Main.storyboard

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
+    </dependencies>
+    <scenes>
+        <!--Flutter View Controller-->
+        <scene sceneID="tne-QT-ifu">
+            <objects>
+                <viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
+                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
+                        <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
+            </objects>
+        </scene>
+    </scenes>
+</document>

+ 77 - 0
mobile/ios/Runner/Info.plist

@@ -0,0 +1,77 @@
+<?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>CADisableMinimumFrameDurationOnPhone</key>
+	<true/>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleDisplayName</key>
+	<string>RackPeek</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>rackpeek_mobile</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(FLUTTER_BUILD_NAME)</string>
+	<key>CFBundleSignature</key>
+	<string>????</string>
+	<key>CFBundleVersion</key>
+	<string>$(FLUTTER_BUILD_NUMBER)</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>NSLocalNetworkUsageDescription</key>
+	<string>RackPeek connects to your own RackPeek server on your local network to show your inventory.</string>
+	<key>NSAppTransportSecurity</key>
+	<dict>
+		<key>NSAllowsArbitraryLoads</key>
+		<true/>
+	</dict>
+	<key>UIApplicationSceneManifest</key>
+	<dict>
+		<key>UIApplicationSupportsMultipleScenes</key>
+		<false/>
+		<key>UISceneConfigurations</key>
+		<dict>
+			<key>UIWindowSceneSessionRoleApplication</key>
+			<array>
+				<dict>
+					<key>UISceneClassName</key>
+					<string>UIWindowScene</string>
+					<key>UISceneConfigurationName</key>
+					<string>flutter</string>
+					<key>UISceneDelegateClassName</key>
+					<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
+					<key>UISceneStoryboardFile</key>
+					<string>Main</string>
+				</dict>
+			</array>
+		</dict>
+	</dict>
+	<key>UIApplicationSupportsIndirectInputEvents</key>
+	<true/>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UIMainStoryboardFile</key>
+	<string>Main</string>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UISupportedInterfaceOrientations~ipad</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+</dict>
+</plist>

+ 1 - 0
mobile/ios/Runner/Runner-Bridging-Header.h

@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"

+ 6 - 0
mobile/ios/Runner/SceneDelegate.swift

@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}

+ 12 - 0
mobile/ios/RunnerTests/RunnerTests.swift

@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+  func testExample() {
+    // If you add code to the Runner application, consider adding tests here.
+    // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+  }
+
+}

+ 108 - 0
mobile/lib/data/api_client.dart

@@ -0,0 +1,108 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+
+import '../models/inventory.dart';
+
+class ApiException implements Exception {
+  ApiException(this.message, {this.statusCode = 0});
+  final String message;
+  final int statusCode;
+
+  @override
+  String toString() => message;
+}
+
+/// Talks to a RackPeek server's REST API. Reads are the GET endpoints;
+/// writes go through `POST /api/inventory` in Merge mode, which never
+/// removes anything — partial updates send only the changed fields.
+class RackPeekApi {
+  RackPeekApi({
+    required this.baseUrl,
+    required this.apiKey,
+    http.Client? client,
+  }) : _client = client ?? http.Client();
+
+  final String baseUrl;
+  final String apiKey;
+  final http.Client _client;
+
+  Map<String, String> get _headers => {'X-Api-Key': apiKey};
+
+  Uri _uri(String path, [Map<String, String>? query]) =>
+      Uri.parse('$baseUrl$path').replace(queryParameters: query);
+
+  Future<InventoryDocument> getDocument() async {
+    final json = await _getJson('/api/inventory');
+    return InventoryDocument.fromJson(json);
+  }
+
+  Future<Map<String, dynamic>> getSummary() async {
+    return _getJson('/api/inventory/summary');
+  }
+
+  Future<List<Map<String, dynamic>>> search(String query, {int max = 20}) async {
+    final json = await _getJson('/api/inventory/search', {'q': query, 'max': '$max'});
+    return (json['matches'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
+  }
+
+  Future<Map<String, dynamic>> upsert(
+    Map<String, dynamic> document, {
+    bool dryRun = false,
+  }) async {
+    final body = jsonEncode({
+      'json': document,
+      'mode': 'Merge',
+      'dryRun': dryRun,
+    });
+
+    final response = await _send(() => _client.post(
+          _uri('/api/inventory'),
+          headers: {..._headers, 'Content-Type': 'application/json'},
+          body: body,
+        ));
+
+    return _decode(response);
+  }
+
+  Future<Map<String, dynamic>> _getJson(String path,
+      [Map<String, String>? query]) async {
+    final response =
+        await _send(() => _client.get(_uri(path, query), headers: _headers));
+    return _decode(response);
+  }
+
+  Future<http.Response> _send(Future<http.Response> Function() send) async {
+    try {
+      return await send();
+    } on http.ClientException catch (e) {
+      throw ApiException('Cannot reach the server: ${e.message}');
+    }
+  }
+
+  Map<String, dynamic> _decode(http.Response response) {
+    Map<String, dynamic>? parsed;
+    try {
+      final body = jsonDecode(response.body);
+      if (body is Map<String, dynamic>) parsed = body;
+    } on FormatException {
+      // fall through to status-based message
+    }
+
+    if (response.statusCode == 200 && parsed != null) return parsed;
+
+    final serverError = parsed?['error'] as String?;
+    switch (response.statusCode) {
+      case 401:
+        throw ApiException('Unauthorized — check the API key.',
+            statusCode: 401);
+      case 503:
+        throw ApiException(
+            'The server has no API key configured (RPK_API_KEY).',
+            statusCode: 503);
+      default:
+        throw ApiException(serverError ?? 'Request failed (${response.statusCode}).',
+            statusCode: response.statusCode);
+    }
+  }
+}

+ 30 - 0
mobile/lib/data/payload.dart

@@ -0,0 +1,30 @@
+/// Builds the minimal `POST /api/inventory` document for a partial update.
+/// Merge mode treats omitted/null/empty fields as "don't touch", so only
+/// changed values are sent.
+Map<String, dynamic> buildUpsertPayload({
+  required int version,
+  required List<Map<String, dynamic>> resources,
+}) {
+  return {
+    'version': version,
+    'resources': resources,
+  };
+}
+
+Map<String, dynamic> resourcePatch({
+  required String kind,
+  required String name,
+  Map<String, Object?> fields = const {},
+  List<String>? tags,
+  Map<String, String>? labels,
+  String? notes,
+  List<String>? runsOn,
+}) {
+  final patch = <String, Object?>{'kind': kind, 'name': name};
+  patch.addAll(fields);
+  if (tags != null) patch['tags'] = tags;
+  if (labels != null) patch['labels'] = labels;
+  if (notes != null) patch['notes'] = notes;
+  if (runsOn != null) patch['runsOn'] = runsOn;
+  return patch;
+}

+ 31 - 0
mobile/lib/data/settings_store.dart

@@ -0,0 +1,31 @@
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+
+/// Server connection details, kept in the platform keychain/keystore.
+class SettingsStore {
+  SettingsStore({FlutterSecureStorage? storage})
+      : _storage = storage ?? const FlutterSecureStorage();
+
+  final FlutterSecureStorage _storage;
+
+  static const _baseUrlKey = 'serverBaseUrl';
+  static const _apiKeyKey = 'serverApiKey';
+
+  Future<({String baseUrl, String apiKey})?> load() async {
+    final baseUrl = await _storage.read(key: _baseUrlKey);
+    final apiKey = await _storage.read(key: _apiKeyKey);
+    if (baseUrl == null || baseUrl.isEmpty || apiKey == null || apiKey.isEmpty) {
+      return null;
+    }
+    return (baseUrl: baseUrl, apiKey: apiKey);
+  }
+
+  Future<void> save(String baseUrl, String apiKey) async {
+    await _storage.write(key: _baseUrlKey, value: baseUrl.trim());
+    await _storage.write(key: _apiKeyKey, value: apiKey.trim());
+  }
+
+  Future<void> clear() async {
+    await _storage.delete(key: _baseUrlKey);
+    await _storage.delete(key: _apiKeyKey);
+  }
+}

+ 435 - 0
mobile/lib/design.dart

@@ -0,0 +1,435 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+/// Semantic color tokens from DESIGN.md (light appearance).
+class ScribeColors {
+  static const Color canvas = Color(0xFFF2F2F7);
+  static const Color surface = Color(0xFFFFFFFF);
+  static const Color primaryText = Color(0xFF1C1C1E);
+  static const Color secondaryText = Color(0xFF63636B);
+  static const Color tertiaryText = Color(0xFF74747C);
+  static const Color accent = Color(0xFF0066D6);
+  static const Color accentText = Color(0xFF005BC4);
+  static const Color accentSoft = Color(0xFFE8F1FF);
+  static const Color separator = Color(0xFFE5E5EA);
+  static const Color strongSeparator = Color(0xFFC7C7CC);
+  static const Color success = Color(0xFF248A3D);
+  static const Color danger = Color(0xFFC9342D);
+  static const Color warningSurface = Color(0xFFFFEBE9);
+  static const Color infoSurface = Color(0xFFE8F1FF);
+}
+
+/// Layout constants from DESIGN.md.
+class ScribeSpacing {
+  static const double screenMargin = 20;
+  static const double panelPadding = 16;
+  static const double majorSectionGap = 26;
+  static const double relatedGap = 10;
+  static const double panelRadius = 20;
+  static const double controlRadius = 12;
+  static const double minTarget = 44;
+}
+
+/// Type scale from DESIGN.md. Platform sans-serif; never clamp text scale.
+class ScribeType {
+  static const TextStyle hero = TextStyle(
+      fontSize: 34, fontWeight: FontWeight.bold, color: ScribeColors.primaryText);
+  static const TextStyle section = TextStyle(
+      fontSize: 28,
+      fontWeight: FontWeight.w600,
+      color: ScribeColors.primaryText);
+  static const TextStyle wordmark = TextStyle(
+      fontSize: 22, fontWeight: FontWeight.bold, color: ScribeColors.primaryText);
+  static const TextStyle title = TextStyle(
+      fontSize: 17,
+      fontWeight: FontWeight.w600,
+      color: ScribeColors.primaryText);
+  static const TextStyle body =
+      TextStyle(fontSize: 16, color: ScribeColors.primaryText);
+  static const TextStyle metadata =
+      TextStyle(fontSize: 13, color: ScribeColors.secondaryText);
+  static const TextStyle sectionLabel = TextStyle(
+      fontSize: 12, color: ScribeColors.tertiaryText, height: 1.1);
+}
+
+ThemeData scribeTheme() {
+  final base = ThemeData(
+    brightness: Brightness.light,
+    useMaterial3: true,
+  );
+  return base.copyWith(
+    scaffoldBackgroundColor: ScribeColors.canvas,
+    colorScheme: const ColorScheme.light(
+      primary: ScribeColors.accent,
+      secondary: ScribeColors.accent,
+      surface: ScribeColors.surface,
+      error: ScribeColors.danger,
+    ),
+    textTheme: base.textTheme.apply(
+      bodyColor: ScribeColors.primaryText,
+      displayColor: ScribeColors.primaryText,
+    ),
+    inputDecorationTheme: InputDecorationTheme(
+      filled: true,
+      fillColor: ScribeColors.surface,
+      border: OutlineInputBorder(
+        borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+        borderSide: const BorderSide(color: ScribeColors.strongSeparator),
+      ),
+      enabledBorder: OutlineInputBorder(
+        borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+        borderSide: const BorderSide(color: ScribeColors.strongSeparator),
+      ),
+      focusedBorder: OutlineInputBorder(
+        borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+        borderSide: const BorderSide(color: ScribeColors.accent, width: 2),
+      ),
+    ),
+    snackBarTheme: const SnackBarThemeData(
+      behavior: SnackBarBehavior.floating,
+      backgroundColor: ScribeColors.primaryText,
+      contentTextStyle: TextStyle(color: Colors.white),
+    ),
+    dialogTheme: DialogThemeData(
+      backgroundColor: ScribeColors.surface,
+      shape: RoundedRectangleBorder(
+        borderRadius: BorderRadius.circular(ScribeSpacing.panelRadius),
+      ),
+    ),
+    bottomSheetTheme: const BottomSheetThemeData(
+      backgroundColor: ScribeColors.canvas,
+    ),
+  );
+}
+
+/// Opaque rounded surface, clipped to its bounds.
+class SurfacePanel extends StatelessWidget {
+  const SurfacePanel({
+    super.key,
+    required this.child,
+    this.padding = const EdgeInsets.all(ScribeSpacing.panelPadding),
+  });
+
+  final Widget child;
+  final EdgeInsetsGeometry padding;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      width: double.infinity,
+      decoration: BoxDecoration(
+        color: ScribeColors.surface,
+        borderRadius: BorderRadius.circular(ScribeSpacing.panelRadius),
+      ),
+      clipBehavior: Clip.antiAlias,
+      padding: padding,
+      child: child,
+    );
+  }
+}
+
+/// Filled primary action or tinted secondary action. 44pt minimum target.
+class ScribeAction extends StatelessWidget {
+  const ScribeAction({
+    super.key,
+    required this.label,
+    this.onPressed,
+    this.secondary = false,
+    this.danger = false,
+    this.busy = false,
+  });
+
+  final String label;
+  final VoidCallback? onPressed;
+  final bool secondary;
+  final bool danger;
+  final bool busy;
+
+  @override
+  Widget build(BuildContext context) {
+    final Color fill = danger
+        ? ScribeColors.danger
+        : secondary
+            ? ScribeColors.accentSoft
+            : ScribeColors.accent;
+    final Color foreground =
+        danger ? Colors.white : secondary ? ScribeColors.accentText : Colors.white;
+
+    return SizedBox(
+      height: ScribeSpacing.minTarget,
+      child: FilledButton(
+        style: FilledButton.styleFrom(
+          backgroundColor: fill,
+          foregroundColor: foreground,
+          disabledBackgroundColor: fill.withValues(alpha: 0.4),
+          disabledForegroundColor: foreground.withValues(alpha: 0.6),
+          shape: RoundedRectangleBorder(
+            borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+          ),
+        ),
+        onPressed: onPressed == null || busy ? null : onPressed,
+        child: busy
+            ? SizedBox(
+                width: 20,
+                height: 20,
+                child: CircularProgressIndicator(
+                    strokeWidth: 2, color: foreground))
+            : Text(label, style: ScribeType.title),
+      ),
+    );
+  }
+}
+
+/// Selectable capsule with selected semantics and a 44pt target.
+class FilterPill extends StatelessWidget {
+  const FilterPill({
+    super.key,
+    required this.label,
+    required this.selected,
+    required this.onTap,
+  });
+
+  final String label;
+  final bool selected;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    return Semantics(
+      selected: selected,
+      button: true,
+      child: InkWell(
+        onTap: onTap,
+        borderRadius: BorderRadius.circular(ScribeSpacing.minTarget / 2),
+        child: Container(
+          height: ScribeSpacing.minTarget - 8,
+          alignment: Alignment.center,
+          padding: const EdgeInsets.symmetric(horizontal: 16),
+          decoration: BoxDecoration(
+            color: selected ? ScribeColors.accent : ScribeColors.surface,
+            borderRadius:
+                BorderRadius.circular(ScribeSpacing.minTarget / 2),
+            border: Border.all(
+              color: selected ? ScribeColors.accent : ScribeColors.strongSeparator,
+            ),
+          ),
+          child: Text(
+            label,
+            style: TextStyle(
+              fontSize: 14,
+              fontWeight: FontWeight.w600,
+              color: selected ? Colors.white : ScribeColors.primaryText,
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// Sentence-case metadata on a quiet neutral fill, with wrapping.
+class NoteFact extends StatelessWidget {
+  const NoteFact({super.key, required this.text});
+
+  final String text;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+      decoration: BoxDecoration(
+        color: ScribeColors.canvas,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Text(text, style: ScribeType.metadata),
+    );
+  }
+}
+
+/// An icon and explanation for information or errors.
+class InlineNotice extends StatelessWidget {
+  const InlineNotice({
+    super.key,
+    required this.message,
+    this.isError = false,
+    this.onRetry,
+  });
+
+  final String message;
+  final bool isError;
+  final VoidCallback? onRetry;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      width: double.infinity,
+      padding: const EdgeInsets.all(ScribeSpacing.panelPadding),
+      decoration: BoxDecoration(
+        color: isError ? ScribeColors.warningSurface : ScribeColors.infoSurface,
+        borderRadius: BorderRadius.circular(ScribeSpacing.controlRadius),
+      ),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Icon(
+            isError ? CupertinoIcons.exclamationmark_triangle : CupertinoIcons.info,
+            size: 20,
+            color: isError ? ScribeColors.danger : ScribeColors.accentText,
+          ),
+          const SizedBox(width: 10),
+          Expanded(
+            child: Text(message, style: ScribeType.body),
+          ),
+          if (onRetry != null)
+            TextButton(onPressed: onRetry, child: const Text('Retry')),
+        ],
+      ),
+    );
+  }
+}
+
+/// A familiar glyph in a tinted 40-point tile.
+class CategoryMark extends StatelessWidget {
+  const CategoryMark({
+    super.key,
+    required this.icon,
+    this.tint = ScribeColors.accent,
+    this.soft = ScribeColors.accentSoft,
+  });
+
+  final IconData icon;
+  final Color tint;
+  final Color soft;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      width: 40,
+      height: 40,
+      decoration: BoxDecoration(
+        color: soft,
+        borderRadius: BorderRadius.circular(10),
+      ),
+      alignment: Alignment.center,
+      child: Icon(icon, size: 20, color: tint),
+    );
+  }
+}
+
+/// Readable sentence-case status with a dot.
+class StatusTag extends StatelessWidget {
+  const StatusTag({
+    super.key,
+    required this.label,
+    required this.color,
+  });
+
+  final String label;
+  final Color color;
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        Container(
+          width: 8,
+          height: 8,
+          decoration: BoxDecoration(color: color, shape: BoxShape.circle),
+        ),
+        const SizedBox(width: 6),
+        Text(label, style: ScribeType.metadata),
+      ],
+    );
+  }
+}
+
+/// Restrained section hierarchy text.
+class SectionLabel extends StatelessWidget {
+  const SectionLabel(this.text, {super.key});
+
+  final String text;
+
+  @override
+  Widget build(BuildContext context) {
+    return Text(text.toUpperCase(),
+        style: ScribeType.sectionLabel.copyWith(letterSpacing: 0.5));
+  }
+}
+
+/// App wordmark.
+class Wordmark extends StatelessWidget {
+  const Wordmark({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return const Text('RackPeek', style: ScribeType.wordmark);
+  }
+}
+
+/// Cupertino activity indicator.
+class ScribeSpinner extends StatelessWidget {
+  const ScribeSpinner({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return const Center(
+      child: Padding(
+        padding: EdgeInsets.all(ScribeSpacing.majorSectionGap),
+        child: CupertinoActivityIndicator(radius: 14),
+      ),
+    );
+  }
+}
+
+/// Scrollable, safe-area-aware column with screen margins.
+class ScribeSheet extends StatelessWidget {
+  const ScribeSheet({
+    super.key,
+    required this.children,
+    this.scrollable = true,
+  });
+
+  final List<Widget> children;
+  final bool scrollable;
+
+  @override
+  Widget build(BuildContext context) {
+    final Column content = Column(
+      crossAxisAlignment: CrossAxisAlignment.stretch,
+      children: _spaced(children),
+    );
+
+    if (!scrollable) {
+      return SafeArea(
+        child: Padding(
+          padding: const EdgeInsets.symmetric(
+              horizontal: ScribeSpacing.screenMargin),
+          child: content,
+        ),
+      );
+    }
+
+    return SafeArea(
+      child: SingleChildScrollView(
+        padding: const EdgeInsets.symmetric(
+            horizontal: ScribeSpacing.screenMargin, vertical: 12),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: _spaced(children),
+        ),
+      ),
+    );
+  }
+
+  static List<Widget> _spaced(List<Widget> children) {
+    final List<Widget> result = [];
+    for (var i = 0; i < children.length; i++) {
+      result.add(children[i]);
+      if (i < children.length - 1) {
+        result.add(const SizedBox(height: ScribeSpacing.majorSectionGap));
+      }
+    }
+    return result;
+  }
+}

+ 68 - 0
mobile/lib/main.dart

@@ -0,0 +1,68 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+import 'data/settings_store.dart';
+import 'design.dart';
+import 'screens/connection_screen.dart';
+import 'screens/home_shell.dart';
+
+void main() {
+  runApp(const RackPeekApp());
+}
+
+class RackPeekApp extends StatelessWidget {
+  const RackPeekApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'RackPeek',
+      debugShowCheckedModeBanner: false,
+      theme: scribeTheme(),
+      home: const _StartupGate(),
+    );
+  }
+}
+
+class _StartupGate extends StatefulWidget {
+  const _StartupGate();
+
+  @override
+  State<_StartupGate> createState() => _StartupGateState();
+}
+
+class _StartupGateState extends State<_StartupGate> {
+  final SettingsStore _settings = SettingsStore();
+  bool _checking = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _startup();
+  }
+
+  Future<void> _startup() async {
+    final saved = await _settings.load();
+    if (!mounted) return;
+    setState(() => _checking = false);
+    if (saved != null) {
+      Navigator.of(context).pushReplacement(MaterialPageRoute(
+        builder: (_) => HomeShell(
+          settings: _settings,
+          baseUrl: saved.baseUrl,
+          apiKey: saved.apiKey,
+        ),
+      ));
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_checking) {
+      return const Scaffold(
+        body: Center(child: CupertinoActivityIndicator(radius: 16)),
+      );
+    }
+    return ConnectionScreen(settings: _settings);
+  }
+}

+ 333 - 0
mobile/lib/models/inventory.dart

@@ -0,0 +1,333 @@
+import 'package:flutter/material.dart';
+
+/// Parsed `GET /api/inventory` document. One flat [Resource] shape carries
+/// every kind's optional fields; kind-specific parsing decides which are set.
+class InventoryDocument {
+  InventoryDocument({
+    required this.version,
+    required this.resources,
+    required this.connections,
+  });
+
+  final int version;
+  final List<Resource> resources;
+  final List<Connection> connections;
+
+  factory InventoryDocument.fromJson(Map<String, dynamic> json) {
+    return InventoryDocument(
+      version: json['version'] as int? ?? 0,
+      resources: (json['resources'] as List<dynamic>? ?? [])
+          .map((e) => Resource.fromJson(e as Map<String, dynamic>))
+          .toList(),
+      connections: (json['connections'] as List<dynamic>? ?? [])
+          .map((e) => Connection.fromJson(e as Map<String, dynamic>))
+          .toList(),
+    );
+  }
+
+  Resource? byName(String name) {
+    for (final resource in resources) {
+      if (resource.name.toLowerCase() == name.toLowerCase()) return resource;
+    }
+    return null;
+  }
+
+  List<Resource> dependantsOf(String name) {
+    return resources
+        .where((r) => r.runsOn
+            .any((parent) => parent.toLowerCase() == name.toLowerCase()))
+        .toList();
+  }
+
+  List<Connection> connectionsFor(String name) {
+    return connections
+        .where((c) =>
+            c.a.resource.toLowerCase() == name.toLowerCase() ||
+            c.b.resource.toLowerCase() == name.toLowerCase())
+        .toList();
+  }
+}
+
+class Resource {
+  Resource({
+    required this.kind,
+    required this.name,
+    this.tags = const [],
+    this.labels = const {},
+    this.notes,
+    this.runsOn = const [],
+    this.discoveryId,
+    // hardware
+    this.ram,
+    this.ipmi,
+    this.cpus = const [],
+    this.drives = const [],
+    this.gpus = const [],
+    this.ports = const [],
+    this.model,
+    this.managed,
+    this.poe,
+    this.speed,
+    this.va,
+    this.description,
+    // system
+    this.type,
+    this.os,
+    this.cores,
+    this.ramGb,
+    this.ip,
+    // service
+    this.network,
+  });
+
+  final String kind;
+  final String name;
+  final List<String> tags;
+  final Map<String, String> labels;
+  final String? notes;
+  final List<String> runsOn;
+  final String? discoveryId;
+
+  final Ram? ram;
+  final bool? ipmi;
+  final List<Cpu> cpus;
+  final List<Drive> drives;
+  final List<Gpu> gpus;
+  final List<PortGroup> ports;
+  final String? model;
+  final bool? managed;
+  final bool? poe;
+  final double? speed;
+  final int? va;
+  final String? description;
+
+  final String? type;
+  final String? os;
+  final int? cores;
+  final double? ramGb;
+  final String? ip;
+
+  final Network? network;
+
+  bool get isHardware => const [
+        'Server',
+        'Switch',
+        'Firewall',
+        'Router',
+        'AccessPoint',
+        'Desktop',
+        'Laptop',
+        'Ups',
+        'Other',
+      ].contains(kind);
+
+  factory Resource.fromJson(Map<String, dynamic> json) {
+    final kind = json['kind'] as String? ?? '';
+    final isSystem = kind == 'System';
+
+    List<T> list<T>(String key, T Function(Map<String, dynamic>) parse) =>
+        (json[key] as List<dynamic>? ?? []).map((e) => parse(e as Map<String, dynamic>)).toList();
+
+    return Resource(
+      kind: kind,
+      name: json['name'] as String? ?? '',
+      tags: (json['tags'] as List<dynamic>? ?? []).cast<String>(),
+      labels: (json['labels'] as Map<String, dynamic>? ?? {})
+          .map((k, v) => MapEntry(k, v.toString())),
+      notes: json['notes'] as String?,
+      runsOn: (json['runsOn'] as List<dynamic>? ?? []).cast<String>(),
+      discoveryId: json['discoveryId'] as String?,
+      ram: isSystem ? null : _parse(json['ram'], (m) => Ram.fromJson(m)),
+      ipmi: json['ipmi'] as bool?,
+      cpus: list('cpus', Cpu.fromJson),
+      drives: list('drives', Drive.fromJson),
+      gpus: list('gpus', Gpu.fromJson),
+      ports: list('ports', PortGroup.fromJson),
+      model: json['model'] as String?,
+      managed: json['managed'] as bool?,
+      poe: json['poe'] as bool?,
+      speed: (json['speed'] as num?)?.toDouble(),
+      va: json['va'] as int?,
+      description: json['description'] as String?,
+      type: json['type'] as String?,
+      os: json['os'] as String?,
+      cores: json['cores'] as int?,
+      ramGb: isSystem ? (json['ram'] as num?)?.toDouble() : null,
+      ip: json['ip'] as String?,
+      network: _parse(json['network'], (m) => Network.fromJson(m)),
+    );
+  }
+
+  static T? _parse<T>(dynamic value, T Function(Map<String, dynamic>) parse) {
+    if (value is Map<String, dynamic>) return parse(value);
+    return null;
+  }
+
+  /// A short human line for list rows.
+  String get subtitle {
+    if (kind == 'Service') {
+      final net = network?.describe();
+      if (net != null && net.isNotEmpty) return net;
+    }
+    if (kind == 'System') {
+      final parts = [type, os, ip].where((p) => p != null && p.isNotEmpty);
+      if (parts.isNotEmpty) return parts.join(' · ');
+    }
+    if (model != null && model!.isNotEmpty) return model!;
+    if (runsOn.isNotEmpty) return 'on ${runsOn.join(', ')}';
+    return kind;
+  }
+
+  IconData get glyph => switch (kind) {
+        'Server' => Icons.dns,
+        'Switch' => Icons.swap_horiz,
+        'Router' => Icons.router,
+        'Firewall' => Icons.lock,
+        'AccessPoint' => Icons.wifi,
+        'Desktop' => Icons.desktop_windows,
+        'Laptop' => Icons.laptop,
+        'Ups' => Icons.bolt,
+        'Other' => Icons.apps,
+        'System' => Icons.memory,
+        'Service' => Icons.public,
+        _ => Icons.help_outline,
+      };
+}
+
+class Ram {
+  Ram({this.size, this.mts});
+  final double? size;
+  final int? mts;
+
+  factory Ram.fromJson(Map<String, dynamic> json) => Ram(
+        size: (json['size'] as num?)?.toDouble(),
+        mts: json['mts'] as int?,
+      );
+}
+
+class Cpu {
+  Cpu({this.model, this.cores, this.threads});
+  final String? model;
+  final int? cores;
+  final int? threads;
+
+  factory Cpu.fromJson(Map<String, dynamic> json) => Cpu(
+        model: json['model'] as String?,
+        cores: json['cores'] as int?,
+        threads: json['threads'] as int?,
+      );
+
+  String describe() => [
+        model,
+        if (cores != null) '$cores cores',
+        if (threads != null) '$threads threads',
+      ].where((p) => p != null).join(' · ');
+}
+
+class Drive {
+  Drive({this.type, this.size});
+  final String? type;
+  final int? size;
+
+  factory Drive.fromJson(Map<String, dynamic> json) => Drive(
+        type: json['type'] as String?,
+        size: json['size'] as int?,
+      );
+
+  String describe() => [
+        if (size != null) '$size GB',
+        type,
+      ].where((p) => p != null).join(' ');
+}
+
+class Gpu {
+  Gpu({this.model, this.vram});
+  final String? model;
+  final int? vram;
+
+  factory Gpu.fromJson(Map<String, dynamic> json) => Gpu(
+        model: json['model'] as String?,
+        vram: json['vram'] as int?,
+      );
+
+  String describe() => [
+        model,
+        if (vram != null) '${vram}GB VRAM',
+      ].where((p) => p != null).join(' · ');
+}
+
+class PortGroup {
+  PortGroup({this.type, this.speed, this.count});
+  final String? type;
+  final double? speed;
+  final int? count;
+
+  factory PortGroup.fromJson(Map<String, dynamic> json) => PortGroup(
+        type: json['type'] as String?,
+        speed: (json['speed'] as num?)?.toDouble(),
+        count: json['count'] as int?,
+      );
+
+  String describe() => [
+        '${count ?? 0} × ${type ?? 'port'}',
+        if (speed != null) '${speed!.toStringAsFixed(0)} Gbps',
+      ].join(' · ');
+}
+
+class Network {
+  Network({this.ip, this.port, this.protocol, this.url});
+  final String? ip;
+  final int? port;
+  final String? protocol;
+  final String? url;
+
+  factory Network.fromJson(Map<String, dynamic> json) => Network(
+        ip: json['ip'] as String?,
+        port: json['port'] as int?,
+        protocol: json['protocol'] as String?,
+        url: json['url'] as String?,
+      );
+
+  String describe() {
+    if (url != null && url!.isNotEmpty) return url!;
+    if (ip == null) return '';
+    return port != null ? '$ip:$port' : ip!;
+  }
+}
+
+class Connection {
+  Connection({required this.a, required this.b, this.label, this.notes});
+  final PortReference a;
+  final PortReference b;
+  final String? label;
+  final String? notes;
+
+  factory Connection.fromJson(Map<String, dynamic> json) => Connection(
+        a: PortReference.fromJson(json['a'] as Map<String, dynamic>),
+        b: PortReference.fromJson(json['b'] as Map<String, dynamic>),
+        label: json['label'] as String?,
+        notes: json['notes'] as String?,
+      );
+
+  String describe() {
+    final ends = [
+      '${a.resource}:${a.portGroup}.$a.portIndex',
+      '${b.resource}:${b.portGroup}.$b.portIndex',
+    ];
+    final link = ends.join(' ↔ ');
+    return label != null && label!.isNotEmpty ? '$link  ($label)' : link;
+  }
+}
+
+class PortReference {
+  PortReference({required this.resource, required this.portGroup, required this.portIndex});
+  final String resource;
+  final int portGroup;
+  final int portIndex;
+
+  factory PortReference.fromJson(Map<String, dynamic> json) => PortReference(
+        resource: json['resource'] as String? ?? '',
+        portGroup: json['portGroup'] as int? ?? 0,
+        portIndex: json['portIndex'] as int? ?? 0,
+      );
+}

+ 170 - 0
mobile/lib/screens/add_resource_screen.dart

@@ -0,0 +1,170 @@
+import 'package:flutter/material.dart';
+
+import '../design.dart';
+import '../models/inventory.dart';
+import '../state/inventory_store.dart';
+
+class AddResourceScreen extends StatefulWidget {
+  const AddResourceScreen({super.key, required this.store});
+
+  final InventoryStore store;
+
+  @override
+  State<AddResourceScreen> createState() => _AddResourceScreenState();
+}
+
+class _AddResourceScreenState extends State<AddResourceScreen> {
+  static const _kinds = [
+    'Server', 'Switch', 'Router', 'Firewall', 'AccessPoint', 'Desktop',
+    'Laptop', 'Ups', 'Other', 'System', 'Service',
+  ];
+
+  String _kind = 'Server';
+  final _name = TextEditingController();
+  String? _parent;
+  String? _error;
+
+  @override
+  void dispose() {
+    _name.dispose();
+    super.dispose();
+  }
+
+  List<Resource> get _parentCandidates {
+    final all = widget.store.document?.resources ?? [];
+    if (_kind == 'System') {
+      return all.where((c) => c.isHardware || c.kind == 'System').toList();
+    }
+    if (_kind == 'Service') {
+      return all.where((c) => c.kind == 'System').toList();
+    }
+    return const [];
+  }
+
+  bool get _needsParent => _kind == 'System' || _kind == 'Service';
+
+  Future<void> _create() async {
+    final name = _name.text.trim();
+    setState(() => _error = null);
+
+    if (name.isEmpty) {
+      setState(() => _error = 'A name is required.');
+      return;
+    }
+    if (name.length > 50) {
+      setState(() => _error = 'Names are limited to 50 characters.');
+      return;
+    }
+    if (widget.store.document?.byName(name) != null) {
+      setState(() => _error = 'A resource named "$name" already exists.');
+      return;
+    }
+    if (_needsParent && _parent == null) {
+      setState(() => _error = 'Pick what this runs on.');
+      return;
+    }
+
+    final resource = <String, Object?>{
+      'kind': _kind,
+      'name': name,
+    };
+    if (_needsParent && _parent != null) resource['runsOn'] = [_parent];
+
+    try {
+      await widget.store.addResource(resource);
+      if (!mounted) return;
+      Navigator.of(context).pop();
+    } catch (e) {
+      setState(() => _error = e.toString());
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        backgroundColor: ScribeColors.canvas,
+        elevation: 0,
+        scrolledUnderElevation: 0,
+        title: const Text('Add resource'),
+      ),
+      body: ScribeSheet(
+        children: [
+          SurfacePanel(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                const SectionLabel('Kind'),
+                const SizedBox(height: 8),
+                Wrap(
+                  spacing: 12,
+                  runSpacing: 12,
+                  children: [
+                    for (final kind in _kinds)
+                      InkWell(
+                        onTap: () => setState(() => _kind = kind),
+                        borderRadius: BorderRadius.circular(10),
+                        child: Column(
+                          mainAxisSize: MainAxisSize.min,
+                          children: [
+                            CategoryMark(
+                              icon: Resource(kind: kind, name: '').glyph,
+                              tint: _kind == kind
+                                  ? Colors.white
+                                  : ScribeColors.accent,
+                              soft: _kind == kind
+                                  ? ScribeColors.accent
+                                  : ScribeColors.accentSoft,
+                            ),
+                            const SizedBox(height: 4),
+                            Text(kind, style: ScribeType.metadata),
+                          ],
+                        ),
+                      ),
+                  ],
+                ),
+              ],
+            ),
+          ),
+          SurfacePanel(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                const SectionLabel('Name'),
+                const SizedBox(height: 8),
+                TextField(
+                  controller: _name,
+                  autocorrect: false,
+                  decoration: const InputDecoration(hintText: 'nas01'),
+                ),
+                if (_needsParent) ...[
+                  const SizedBox(height: ScribeSpacing.majorSectionGap),
+                  SectionLabel(_kind == 'Service' ? 'Runs on (system)' : 'Runs on'),
+                  const SizedBox(height: 8),
+                  DropdownButtonFormField<String>(
+                    initialValue: _parent,
+                    decoration: const InputDecoration(hintText: 'Choose a parent'),
+                    items: [
+                      for (final candidate in _parentCandidates)
+                        DropdownMenuItem(
+                          value: candidate.name,
+                          child: Text(candidate.name),
+                        ),
+                    ],
+                    onChanged: (v) => setState(() => _parent = v),
+                  ),
+                ],
+              ],
+            ),
+          ),
+          if (_error != null) InlineNotice(message: _error!, isError: true),
+          ScribeAction(
+            label: 'Create',
+            busy: widget.store.busy,
+            onPressed: _create,
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 134 - 0
mobile/lib/screens/connection_screen.dart

@@ -0,0 +1,134 @@
+import 'package:flutter/material.dart';
+
+import '../data/api_client.dart';
+import '../data/settings_store.dart';
+import '../design.dart';
+import 'home_shell.dart';
+import 'privacy_policy_screen.dart';
+
+class ConnectionScreen extends StatefulWidget {
+  const ConnectionScreen({super.key, required this.settings});
+
+  final SettingsStore settings;
+
+  @override
+  State<ConnectionScreen> createState() => _ConnectionScreenState();
+}
+
+class _ConnectionScreenState extends State<ConnectionScreen> {
+  final _baseUrl = TextEditingController();
+  final _apiKey = TextEditingController();
+  bool _busy = false;
+  String? _error;
+
+  @override
+  void dispose() {
+    _baseUrl.dispose();
+    _apiKey.dispose();
+    super.dispose();
+  }
+
+  Future<void> _connect() async {
+    final baseUrl = _baseUrl.text.trim().replaceAll(RegExp(r'/+$'), '');
+    final apiKey = _apiKey.text.trim();
+
+    if (baseUrl.isEmpty || !baseUrl.startsWith('http')) {
+      setState(
+        () => _error = 'Enter the server URL, e.g. http://192.168.1.10:8080',
+      );
+      return;
+    }
+    if (apiKey.isEmpty) {
+      setState(() => _error = 'Enter the API key (RPK_API_KEY on the server).');
+      return;
+    }
+
+    setState(() {
+      _busy = true;
+      _error = null;
+    });
+
+    try {
+      await RackPeekApi(baseUrl: baseUrl, apiKey: apiKey).getSummary();
+      await widget.settings.save(baseUrl, apiKey);
+      if (!mounted) return;
+      Navigator.of(context).pushReplacement(
+        MaterialPageRoute(
+          builder: (_) => HomeShell(
+            settings: widget.settings,
+            baseUrl: baseUrl,
+            apiKey: apiKey,
+          ),
+        ),
+      );
+    } on ApiException catch (e) {
+      setState(() => _error = e.message);
+    } finally {
+      if (mounted) setState(() => _busy = false);
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: ScribeSheet(
+        children: [
+          const SizedBox(height: 32),
+          const Center(child: Wordmark()),
+          const Text(
+            'Your infrastructure, in your pocket. Connect to a RackPeek server '
+            'to browse and edit your inventory.',
+            textAlign: TextAlign.center,
+            style: ScribeType.body,
+          ),
+          SurfacePanel(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                const SectionLabel('Server'),
+                const SizedBox(height: ScribeSpacing.relatedGap),
+                TextField(
+                  controller: _baseUrl,
+                  keyboardType: TextInputType.url,
+                  autocorrect: false,
+                  decoration: const InputDecoration(
+                    hintText: 'http://192.168.1.10:8080',
+                    labelText: 'Server URL',
+                  ),
+                ),
+                const SizedBox(height: ScribeSpacing.relatedGap),
+                const SectionLabel('API key'),
+                const SizedBox(height: ScribeSpacing.relatedGap),
+                TextField(
+                  controller: _apiKey,
+                  obscureText: true,
+                  autocorrect: false,
+                  decoration: const InputDecoration(
+                    labelText: 'API key',
+                    helperText:
+                        'Stored in your device keychain. Never leaves your network.',
+                  ),
+                ),
+                const SizedBox(height: ScribeSpacing.majorSectionGap),
+                ScribeAction(
+                  label: 'Connect',
+                  busy: _busy,
+                  onPressed: _connect,
+                ),
+              ],
+            ),
+          ),
+          if (_error != null) InlineNotice(message: _error!, isError: true),
+          Center(
+            child: TextButton(
+              onPressed: () => Navigator.of(context).push(
+                MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen()),
+              ),
+              child: const Text('Privacy policy'),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 544 - 0
mobile/lib/screens/edit_resource_screen.dart

@@ -0,0 +1,544 @@
+import 'package:flutter/material.dart';
+
+import '../data/payload.dart';
+import '../design.dart';
+import '../models/inventory.dart';
+import '../state/inventory_store.dart';
+
+class EditResourceScreen extends StatefulWidget {
+  const EditResourceScreen({
+    super.key,
+    required this.store,
+    required this.resource,
+  });
+
+  final InventoryStore store;
+  final Resource resource;
+
+  @override
+  State<EditResourceScreen> createState() => _EditResourceScreenState();
+}
+
+class _EditResourceScreenState extends State<EditResourceScreen> {
+  late final TextEditingController _model;
+  late final TextEditingController _description;
+  late final TextEditingController _ramSize;
+  late final TextEditingController _ramMts;
+  late final TextEditingController _os;
+  late final TextEditingController _cores;
+  late final TextEditingController _systemRam;
+  late final TextEditingController _ip;
+  late final TextEditingController _port;
+  late final TextEditingController _protocol;
+  late final TextEditingController _url;
+  late final TextEditingController _speed;
+  late final TextEditingController _va;
+  late final TextEditingController _notes;
+  late final TextEditingController _newTag;
+
+  late bool? _ipmi;
+  late bool? _managed;
+  late bool? _poe;
+  late String? _type;
+  late List<String> _tags;
+  late Map<String, String> _labels;
+  late List<String> _runsOn;
+
+  String? _error;
+
+  static const _systemTypes = [
+    'baremetal', 'hypervisor', 'vm', 'container',
+    'embedded', 'cloud', 'cluster', 'other',
+  ];
+
+  @override
+  void initState() {
+    super.initState();
+    final r = widget.resource;
+    _model = TextEditingController(text: r.model ?? '');
+    _description = TextEditingController(text: r.description ?? '');
+    _ramSize = TextEditingController(
+        text: r.ram?.size != null ? r.ram!.size!.toStringAsFixed(0) : '');
+    _ramMts = TextEditingController(text: r.ram?.mts?.toString() ?? '');
+    _os = TextEditingController(text: r.os ?? '');
+    _cores = TextEditingController(text: r.cores?.toString() ?? '');
+    _systemRam = TextEditingController(
+        text: r.ramGb != null ? r.ramGb!.toStringAsFixed(0) : '');
+    _ip = TextEditingController(text: r.ip ?? r.network?.ip ?? '');
+    _port = TextEditingController(text: r.network?.port?.toString() ?? '');
+    _protocol = TextEditingController(text: r.network?.protocol ?? '');
+    _url = TextEditingController(text: r.network?.url ?? '');
+    _speed = TextEditingController(
+        text: r.speed != null ? r.speed!.toStringAsFixed(0) : '');
+    _va = TextEditingController(text: r.va?.toString() ?? '');
+    _notes = TextEditingController(text: r.notes ?? '');
+    _newTag = TextEditingController();
+
+    _ipmi = r.ipmi;
+    _managed = r.managed;
+    _poe = r.poe;
+    _type = r.type;
+    _tags = List.of(r.tags);
+    _labels = Map.of(r.labels);
+    _runsOn = List.of(r.runsOn);
+  }
+
+  @override
+  void dispose() {
+    for (final controller in [
+      _model, _description, _ramSize, _ramMts, _os, _cores, _systemRam,
+      _ip, _port, _protocol, _url, _speed, _va, _notes, _newTag,
+      _newLabelKey, _newLabelValue,
+    ]) {
+      controller.dispose();
+    }
+    super.dispose();
+  }
+
+  Map<String, Object?> _scalarFields() {
+    final r = widget.resource;
+    final fields = <String, Object?>{};
+
+    void maybeString(String key, TextEditingController controller, String? original) {
+      final value = controller.text.trim();
+      if (value.isEmpty) return;
+      if (value != (original ?? '')) fields[key] = value;
+    }
+
+    void maybeInt(String key, TextEditingController controller, int? original) {
+      final value = int.tryParse(controller.text.trim());
+      if (value == null || value == original) return;
+      fields[key] = value;
+    }
+
+    void maybeDouble(String key, TextEditingController controller, double? original) {
+      final value = double.tryParse(controller.text.trim());
+      if (value == null || value == original) return;
+      fields[key] = value;
+    }
+
+    switch (r.kind) {
+      case 'Server':
+        maybeString('model', _model, r.model);
+        final serverRam = _ramPatch();
+        if (serverRam != null) fields['ram'] = serverRam;
+        if (_ipmi != r.ipmi && _ipmi != null) fields['ipmi'] = _ipmi;
+      case 'Desktop' || 'Laptop':
+        maybeString('model', _model, r.model);
+        final portableRam = _ramPatch();
+        if (portableRam != null) fields['ram'] = portableRam;
+      case 'Switch' || 'Router' || 'Firewall':
+        maybeString('model', _model, r.model);
+        if (_managed != r.managed && _managed != null) fields['managed'] = _managed;
+        if (_poe != r.poe && _poe != null) fields['poe'] = _poe;
+      case 'AccessPoint':
+        maybeString('model', _model, r.model);
+        maybeDouble('speed', _speed, r.speed);
+      case 'Ups':
+        maybeString('model', _model, r.model);
+        maybeInt('va', _va, r.va);
+      case 'Other':
+        maybeString('model', _model, r.model);
+        maybeString('description', _description, r.description);
+      case 'System':
+        if (_type != null && _type != r.type) fields['type'] = _type;
+        maybeString('os', _os, r.os);
+        maybeInt('cores', _cores, r.cores);
+        maybeDouble('ram', _systemRam, r.ramGb);
+        maybeString('ip', _ip, r.ip);
+        if (!_listEquals(_runsOn, r.runsOn)) fields['runsOn'] = _runsOn;
+      case 'Service':
+        final network = <String, Object?>{};
+        maybeNetwork(network);
+        if (network.isNotEmpty) fields['network'] = network;
+        if (!_listEquals(_runsOn, r.runsOn)) fields['runsOn'] = _runsOn;
+    }
+    return fields;
+  }
+
+  Map<String, Object?>? _ramPatch() {
+    final original = widget.resource.ram;
+    final size = double.tryParse(_ramSize.text.trim());
+    final mts = int.tryParse(_ramMts.text.trim());
+    final ram = <String, Object?>{};
+    if (size != null && size != original?.size) ram['size'] = size;
+    if (mts != null && mts != original?.mts) ram['mts'] = mts;
+    return ram.isEmpty ? null : ram;
+  }
+
+  void maybeNetwork(Map<String, Object?> network) {
+    final original = widget.resource.network;
+    final ip = _ip.text.trim();
+    final port = int.tryParse(_port.text.trim());
+    final protocol = _protocol.text.trim();
+    final url = _url.text.trim();
+    if (ip.isNotEmpty && ip != original?.ip) network['ip'] = ip;
+    if (port != null && port != original?.port) network['port'] = port;
+    if (protocol.isNotEmpty && protocol != original?.protocol) {
+      network['protocol'] = protocol;
+    }
+    if (url.isNotEmpty && url != original?.url) network['url'] = url;
+  }
+
+  static bool _listEquals(List<String> a, List<String> b) {
+    if (a.length != b.length) return false;
+    for (var i = 0; i < a.length; i++) {
+      if (a[i] != b[i]) return false;
+    }
+    return true;
+  }
+
+  Future<void> _save() async {
+    setState(() => _error = null);
+
+    final fields = _scalarFields();
+    final tagsChanged = !_listEquals(_tags, widget.resource.tags);
+    final labelsChanged = !_mapsEqual(_labels, widget.resource.labels);
+    final notesChanged = _notes.text != (widget.resource.notes ?? '');
+
+    if (fields.isEmpty && !tagsChanged && !labelsChanged && !notesChanged) {
+      Navigator.of(context).pop();
+      return;
+    }
+
+    try {
+      await widget.store.applyPatch(resourcePatch(
+        kind: widget.resource.kind,
+        name: widget.resource.name,
+        fields: fields,
+        tags: tagsChanged ? _tags : null,
+        labels: labelsChanged ? _labels : null,
+        notes: notesChanged ? _notes.text : null,
+      ));
+      if (!mounted) return;
+      Navigator.of(context).pop();
+    } catch (e) {
+      setState(() => _error = e.toString());
+    }
+  }
+
+  static bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
+    if (a.length != b.length) return false;
+    for (final entry in a.entries) {
+      if (b[entry.key] != entry.value) return false;
+    }
+    return true;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final r = widget.resource;
+
+    return Scaffold(
+      appBar: AppBar(
+        backgroundColor: ScribeColors.canvas,
+        elevation: 0,
+        scrolledUnderElevation: 0,
+        title: Text('Edit ${r.name}'),
+      ),
+      body: ScribeSheet(
+        children: [
+          if (_hasScalars) ...[
+            SurfacePanel(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.stretch,
+                children: [
+                  const SectionLabel('Details'),
+                  const SizedBox(height: ScribeSpacing.relatedGap),
+                  ..._scalarInputs(),
+                ],
+              ),
+            ),
+          ],
+          _tagsPanel(),
+          _labelsPanel(),
+          SurfacePanel(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                const SectionLabel('Notes'),
+                const SizedBox(height: ScribeSpacing.relatedGap),
+                TextField(
+                  controller: _notes,
+                  minLines: 3,
+                  maxLines: 10,
+                  decoration: const InputDecoration(
+                    hintText: 'Markdown, as in the web UI',
+                  ),
+                ),
+              ],
+            ),
+          ),
+          if (_error != null) InlineNotice(message: _error!, isError: true),
+          ScribeAction(
+            label: 'Save',
+            busy: widget.store.busy,
+            onPressed: _save,
+          ),
+          const Text(
+            'Merge semantics: empty fields are left untouched on the server. '
+            'Deleting a resource or clearing all tags is done from the web UI.',
+            style: ScribeType.metadata,
+          ),
+        ],
+      ),
+    );
+  }
+
+  bool get _hasScalars => const {
+        'Server', 'Desktop', 'Laptop', 'Switch', 'Router', 'Firewall',
+        'AccessPoint', 'Ups', 'Other', 'System', 'Service',
+      }.contains(widget.resource.kind);
+
+  List<Widget> _scalarInputs() {
+    switch (widget.resource.kind) {
+      case 'Server':
+        return [
+          _field(_ramSize, 'RAM (GB)', TextInputType.number),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_ramMts, 'RAM speed (MT/s)', TextInputType.number),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _triSwitch('IPMI', _ipmi, (v) => setState(() => _ipmi = v)),
+        ];
+      case 'Desktop' || 'Laptop':
+        return [
+          _field(_model, 'Model'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_ramSize, 'RAM (GB)', TextInputType.number),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_ramMts, 'RAM speed (MT/s)', TextInputType.number),
+        ];
+      case 'Switch' || 'Router' || 'Firewall':
+        return [
+          _field(_model, 'Model'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _triSwitch('Managed', _managed, (v) => setState(() => _managed = v)),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _triSwitch('PoE', _poe, (v) => setState(() => _poe = v)),
+        ];
+      case 'AccessPoint':
+        return [
+          _field(_model, 'Model'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_speed, 'Speed (Gbps)', TextInputType.number),
+        ];
+      case 'Ups':
+        return [
+          _field(_model, 'Model'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_va, 'VA', TextInputType.number),
+        ];
+      case 'Other':
+        return [
+          _field(_model, 'Model'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_description, 'Description'),
+        ];
+      case 'System':
+        return [
+          DropdownButtonFormField<String>(
+            initialValue: _type,
+            decoration: const InputDecoration(labelText: 'Type'),
+            items: [
+              for (final t in _systemTypes)
+                DropdownMenuItem(value: t, child: Text(t)),
+            ],
+            onChanged: (v) => setState(() => _type = v),
+          ),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_os, 'OS'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_cores, 'Cores', TextInputType.number),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_systemRam, 'RAM (GB)', TextInputType.number),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_ip, 'IP'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _runsOnPicker(),
+        ];
+      case 'Service':
+        return [
+          _field(_ip, 'IP'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_port, 'Port', TextInputType.number),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_protocol, 'Protocol'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _field(_url, 'URL'),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _runsOnPicker(),
+        ];
+      default:
+        return [];
+    }
+  }
+
+  Widget _runsOnPicker() {
+    final candidates = (widget.store.document?.resources ?? [])
+        .where((c) {
+          if (c.name == widget.resource.name) return false;
+          if (widget.resource.kind == 'System') {
+            return c.isHardware || c.kind == 'System';
+          }
+          return c.kind == 'System';
+        })
+        .toList();
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        const SectionLabel('Runs on'),
+        const SizedBox(height: 8),
+        for (final candidate in candidates)
+          CheckboxListTile(
+            contentPadding: EdgeInsets.zero,
+            controlAffinity: ListTileControlAffinity.leading,
+            value: _runsOn.contains(candidate.name),
+            title: Text(candidate.name, style: ScribeType.body),
+            onChanged: (checked) => setState(() {
+              if (checked == true) {
+                _runsOn = [candidate.name];
+              } else {
+                _runsOn = [];
+              }
+            }),
+          ),
+      ],
+    );
+  }
+
+  Widget _tagsPanel() {
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: [
+          const SectionLabel('Tags'),
+          const SizedBox(height: 8),
+          Wrap(
+            spacing: 8,
+            runSpacing: 8,
+            children: [
+              for (final tag in _tags)
+                InputChip(
+                  label: Text(tag),
+                  onDeleted: () => setState(() => _tags.remove(tag)),
+                ),
+            ],
+          ),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          Row(
+            children: [
+              Expanded(
+                child: TextField(
+                  controller: _newTag,
+                  decoration: const InputDecoration(hintText: 'Add tag'),
+                  onSubmitted: (_) => _addTag(),
+                ),
+              ),
+              const SizedBox(width: 8),
+              IconButton(
+                tooltip: 'Add tag',
+                onPressed: _addTag,
+                icon: const Icon(Icons.add_circle),
+                color: ScribeColors.accent,
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+
+  void _addTag() {
+    final tag = _newTag.text.trim();
+    if (tag.isEmpty || _tags.contains(tag)) return;
+    setState(() {
+      _tags.add(tag);
+      _newTag.clear();
+    });
+  }
+
+  Widget _labelsPanel() {
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: [
+          const SectionLabel('Labels'),
+          const SizedBox(height: 8),
+          for (final entry in _labels.entries)
+            Row(
+              children: [
+                Expanded(child: Text('${entry.key}: ${entry.value}', style: ScribeType.body)),
+                IconButton(
+                  tooltip: 'Remove label ${entry.key}',
+                  icon: const Icon(Icons.close),
+                  color: ScribeColors.tertiaryText,
+                  onPressed: () => setState(() => _labels.remove(entry.key)),
+                ),
+              ],
+            ),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          _labelAdder(),
+        ],
+      ),
+    );
+  }
+
+  final TextEditingController _newLabelKey = TextEditingController();
+  final TextEditingController _newLabelValue = TextEditingController();
+
+  Widget _labelAdder() {
+    return Row(
+      children: [
+        Expanded(
+          child: TextField(
+            controller: _newLabelKey,
+            decoration: const InputDecoration(hintText: 'Key'),
+          ),
+        ),
+        const SizedBox(width: 8),
+        Expanded(
+          child: TextField(
+            controller: _newLabelValue,
+            decoration: const InputDecoration(hintText: 'Value'),
+          ),
+        ),
+        const SizedBox(width: 8),
+        IconButton(
+          tooltip: 'Add label',
+          icon: const Icon(Icons.add_circle),
+          color: ScribeColors.accent,
+          onPressed: () {
+            final key = _newLabelKey.text.trim();
+            final value = _newLabelValue.text.trim();
+            if (key.isEmpty || value.isEmpty) return;
+            setState(() {
+              _labels[key] = value;
+              _newLabelKey.clear();
+              _newLabelValue.clear();
+            });
+          },
+        ),
+      ],
+    );
+  }
+
+  Widget _field(TextEditingController controller, String label,
+      [TextInputType type = TextInputType.text]) {
+    return TextField(
+      controller: controller,
+      keyboardType: type,
+      decoration: InputDecoration(labelText: label),
+    );
+  }
+
+  Widget _triSwitch(String label, bool? value, ValueChanged<bool?> onChanged) {
+    return SwitchListTile(
+      contentPadding: EdgeInsets.zero,
+      title: Text(label, style: ScribeType.body),
+      value: value ?? false,
+      onChanged: onChanged,
+      activeThumbColor: ScribeColors.accent,
+    );
+  }
+}

+ 168 - 0
mobile/lib/screens/home_shell.dart

@@ -0,0 +1,168 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+import '../data/api_client.dart';
+import '../data/settings_store.dart';
+import '../design.dart';
+import '../state/inventory_store.dart';
+import 'connection_screen.dart';
+import 'privacy_policy_screen.dart';
+import 'resources_tab.dart';
+import 'summary_tab.dart';
+import 'tree_tab.dart';
+
+class HomeShell extends StatefulWidget {
+  const HomeShell({
+    super.key,
+    required this.settings,
+    required this.baseUrl,
+    required this.apiKey,
+  });
+
+  final SettingsStore settings;
+  final String baseUrl;
+  final String apiKey;
+
+  @override
+  State<HomeShell> createState() => _HomeShellState();
+}
+
+class _HomeShellState extends State<HomeShell> {
+  late final InventoryStore _store;
+  int _index = 0;
+
+  @override
+  void initState() {
+    super.initState();
+    _store = InventoryStore(
+      RackPeekApi(baseUrl: widget.baseUrl, apiKey: widget.apiKey),
+    );
+    _store.refresh();
+  }
+
+  @override
+  void dispose() {
+    _store.dispose();
+    super.dispose();
+  }
+
+  Future<void> _openSettings() async {
+    await showModalBottomSheet<void>(
+      context: context,
+      showDragHandle: true,
+      builder: (sheetContext) => SafeArea(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: [
+            Padding(
+              padding: const EdgeInsets.symmetric(
+                horizontal: ScribeSpacing.panelPadding,
+              ),
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  const Text('Connected to', style: ScribeType.sectionLabel),
+                  const SizedBox(height: 4),
+                  Text(widget.baseUrl, style: ScribeType.body),
+                  const SizedBox(height: ScribeSpacing.majorSectionGap),
+                  ScribeAction(
+                    label: 'Privacy policy',
+                    secondary: true,
+                    onPressed: () {
+                      Navigator.of(sheetContext).pop();
+                      Navigator.of(context).push(
+                        MaterialPageRoute(
+                          builder: (_) => const PrivacyPolicyScreen(),
+                        ),
+                      );
+                    },
+                  ),
+                  const SizedBox(height: ScribeSpacing.relatedGap),
+                  ScribeAction(
+                    label: 'Refresh inventory',
+                    secondary: true,
+                    onPressed: () {
+                      _store.refresh();
+                      Navigator.of(sheetContext).pop();
+                    },
+                  ),
+                  const SizedBox(height: ScribeSpacing.relatedGap),
+                  ScribeAction(
+                    label: 'Disconnect',
+                    danger: true,
+                    onPressed: () async {
+                      await widget.settings.clear();
+                      if (!sheetContext.mounted) return;
+                      Navigator.of(sheetContext).pop();
+                      if (!mounted) return;
+                      Navigator.of(context).pushAndRemoveUntil(
+                        MaterialPageRoute(
+                          builder: (_) =>
+                              ConnectionScreen(settings: widget.settings),
+                        ),
+                        (route) => false,
+                      );
+                    },
+                  ),
+                ],
+              ),
+            ),
+            const SizedBox(height: ScribeSpacing.majorSectionGap),
+          ],
+        ),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: _store,
+      builder: (context, _) {
+        return Scaffold(
+          appBar: AppBar(
+            backgroundColor: ScribeColors.canvas,
+            elevation: 0,
+            scrolledUnderElevation: 0,
+            title: const Wordmark(),
+            actions: [
+              IconButton(
+                tooltip: 'Settings',
+                icon: const Icon(CupertinoIcons.gear),
+                onPressed: _openSettings,
+              ),
+            ],
+          ),
+          body: IndexedStack(
+            index: _index,
+            children: [
+              SummaryTab(store: _store),
+              ResourcesTab(store: _store),
+              TreeTab(store: _store),
+            ],
+          ),
+          bottomNavigationBar: NavigationBar(
+            backgroundColor: ScribeColors.surface,
+            selectedIndex: _index,
+            onDestinationSelected: (i) => setState(() => _index = i),
+            destinations: const [
+              NavigationDestination(
+                icon: Icon(CupertinoIcons.chart_bar),
+                label: 'Summary',
+              ),
+              NavigationDestination(
+                icon: Icon(CupertinoIcons.list_bullet),
+                label: 'Resources',
+              ),
+              NavigationDestination(
+                icon: Icon(CupertinoIcons.tree),
+                label: 'Tree',
+              ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+}

+ 79 - 0
mobile/lib/screens/privacy_policy_screen.dart

@@ -0,0 +1,79 @@
+import 'package:flutter/material.dart';
+
+import '../design.dart';
+
+class PrivacyPolicyScreen extends StatelessWidget {
+  const PrivacyPolicyScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        backgroundColor: ScribeColors.canvas,
+        elevation: 0,
+        scrolledUnderElevation: 0,
+        title: const Text('Privacy policy'),
+      ),
+      body: ScribeSheet(
+        children: const [
+          Text('Effective date: 22 October 2026', style: ScribeType.metadata),
+          Text(
+            'RackPeek for iOS connects to a RackPeek server that you choose and operate.',
+            style: ScribeType.body,
+          ),
+          _PolicySection(
+            title: 'Information the app uses',
+            body:
+                'The server address and API key you enter are saved in secure storage on your device. The app sends the API key with requests to that server. Inventory is received from the server and shown in the app; changes you make are sent back to it. The RackPeek app publisher does not receive this information through an app-operated service. Your server operator may keep its own logs and inventory.',
+          ),
+          _PolicySection(
+            title: 'Network security',
+            body:
+                'Use HTTPS when your server supports it. With unencrypted HTTP, your API key and inventory traffic may be visible to other systems on the network. Only connect to a server you trust.',
+          ),
+          _PolicySection(
+            title: 'Analytics and tracking',
+            body:
+                'The current app does not include advertising, analytics, or cross-app tracking features.',
+          ),
+          _PolicySection(
+            title: 'Your choices',
+            body:
+                'Choose Disconnect in settings to remove the saved server address and API key from this device. This does not delete information from your RackPeek server.',
+          ),
+          _PolicySection(
+            title: "Children's privacy",
+            body:
+                'RackPeek is a general-purpose infrastructure inventory tool and is not designed for children.',
+          ),
+          _PolicySection(
+            title: 'Contact',
+            body:
+                'For privacy questions, contact the app publisher through the project support page at github.com/Timmoth/RackPeek/issues.',
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _PolicySection extends StatelessWidget {
+  const _PolicySection({required this.title, required this.body});
+
+  final String title;
+  final String body;
+
+  @override
+  Widget build(BuildContext context) {
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(title, style: ScribeType.sectionLabel),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          Text(body, style: ScribeType.body),
+        ],
+      ),
+    );
+  }
+}

+ 234 - 0
mobile/lib/screens/resource_detail_screen.dart

@@ -0,0 +1,234 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+import '../design.dart';
+import '../models/inventory.dart';
+import '../state/inventory_store.dart';
+import 'edit_resource_screen.dart';
+
+class ResourceDetailScreen extends StatelessWidget {
+  const ResourceDetailScreen({
+    super.key,
+    required this.store,
+    required this.name,
+  });
+
+  final InventoryStore store;
+  final String name;
+
+  @override
+  Widget build(BuildContext context) {
+    final document = store.document;
+    final resource = document?.byName(name);
+
+    if (document == null || resource == null) {
+      return Scaffold(
+        appBar: AppBar(title: const Text('Resource')),
+        body: const ScribeSpinner(),
+      );
+    }
+
+    final dependants = document.dependantsOf(resource.name);
+    final connections = document.connectionsFor(resource.name);
+
+    return Scaffold(
+      appBar: AppBar(
+        backgroundColor: ScribeColors.canvas,
+        elevation: 0,
+        scrolledUnderElevation: 0,
+        title: Text(resource.name),
+      ),
+      body: RefreshIndicator(
+        onRefresh: store.refresh,
+        child: ScribeSheet(
+          children: [
+            Row(
+              children: [
+                CategoryMark(icon: resource.glyph),
+                const SizedBox(width: 12),
+                Expanded(
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Text(resource.name, style: ScribeType.hero),
+                      Text(resource.kind, style: ScribeType.metadata),
+                    ],
+                  ),
+                ),
+              ],
+            ),
+            SurfacePanel(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  const SectionLabel('Details'),
+                  const SizedBox(height: ScribeSpacing.relatedGap),
+                  _facts(context, resource),
+                ],
+              ),
+            ),
+            if (resource.runsOn.isNotEmpty)
+              _linksPanel('Runs on', resource.runsOn, context),
+            if (dependants.isNotEmpty)
+              _linksPanel(
+                'Dependants',
+                dependants.map((r) => r.name).toList(),
+                context,
+              ),
+            if (connections.isNotEmpty)
+              SurfacePanel(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    const SectionLabel('Connections'),
+                    const SizedBox(height: ScribeSpacing.relatedGap),
+                    for (final connection in connections)
+                      Padding(
+                        padding: const EdgeInsets.only(bottom: 6),
+                        child: Text(connection.describe(), style: ScribeType.body),
+                      ),
+                  ],
+                ),
+              ),
+            if (resource.tags.isNotEmpty || resource.labels.isNotEmpty)
+              SurfacePanel(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    if (resource.tags.isNotEmpty) ...[
+                      const SectionLabel('Tags'),
+                      const SizedBox(height: 8),
+                      Wrap(
+                        spacing: 8,
+                        runSpacing: 8,
+                        children: [
+                          for (final tag in resource.tags) NoteFact(text: tag),
+                        ],
+                      ),
+                    ],
+                    if (resource.labels.isNotEmpty) ...[
+                      const SizedBox(height: ScribeSpacing.relatedGap),
+                      const SectionLabel('Labels'),
+                      const SizedBox(height: 8),
+                      Wrap(
+                        spacing: 8,
+                        runSpacing: 8,
+                        children: [
+                          for (final entry in resource.labels.entries)
+                            NoteFact(text: '${entry.key}: ${entry.value}'),
+                        ],
+                      ),
+                    ],
+                  ],
+                ),
+              ),
+            if (resource.notes != null && resource.notes!.isNotEmpty)
+              SurfacePanel(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    const SectionLabel('Notes'),
+                    const SizedBox(height: 8),
+                    SelectableText(resource.notes!, style: ScribeType.body),
+                  ],
+                ),
+              ),
+            ScribeAction(
+              label: 'Edit',
+              secondary: true,
+              onPressed: () => Navigator.of(context).push(MaterialPageRoute(
+                builder: (_) =>
+                    EditResourceScreen(store: store, resource: resource),
+              )),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _facts(BuildContext context, Resource resource) {
+    final facts = <String>[];
+
+    void add(String? value) {
+      if (value != null && value.isNotEmpty) facts.add(value);
+    }
+
+    add(resource.model);
+    if (resource.ram?.size != null) {
+      final mts = resource.ram!.mts;
+      add('RAM ${resource.ram!.size!.toStringAsFixed(0)} GB'
+          '${mts != null ? ' ($mts)' : ''}');
+    }
+    if (resource.ipmi == true) add('IPMI');
+    if (resource.managed != null) add(resource.managed! ? 'Managed' : 'Unmanaged');
+    if (resource.poe != null) add(resource.poe! ? 'PoE' : 'No PoE');
+    if (resource.speed != null) {
+      add('${resource.speed!.toStringAsFixed(0)} Gbps');
+    }
+    if (resource.va != null) add('${resource.va} VA');
+    add(resource.description);
+    add(resource.type);
+    add(resource.os);
+    if (resource.cores != null) add('${resource.cores} cores');
+    if (resource.ramGb != null) {
+      add('RAM ${resource.ramGb!.toStringAsFixed(0)} GB');
+    }
+    add(resource.ip);
+    final network = resource.network?.describe();
+    if (network != null && network.isNotEmpty) add(network);
+
+    for (final cpu in resource.cpus) {
+      add('CPU: ${cpu.describe()}');
+    }
+    for (final drive in resource.drives) {
+      add('Drive: ${drive.describe()}');
+    }
+    for (final gpu in resource.gpus) {
+      add('GPU: ${gpu.describe()}');
+    }
+    for (final port in resource.ports) {
+      add('Ports: ${port.describe()}');
+    }
+
+    if (facts.isEmpty) return const Text('No details yet.', style: ScribeType.metadata);
+
+    return Wrap(
+      spacing: 8,
+      runSpacing: 8,
+      children: [for (final fact in facts) NoteFact(text: fact)],
+    );
+  }
+
+  Widget _linksPanel(String title, List<String> names, BuildContext context) {
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SectionLabel(title),
+          const SizedBox(height: 8),
+          for (final linkName in names)
+            InkWell(
+              onTap: () => Navigator.of(context).push(MaterialPageRoute(
+                builder: (_) =>
+                    ResourceDetailScreen(store: store, name: linkName),
+              )),
+              child: Padding(
+                padding: const EdgeInsets.symmetric(vertical: 6),
+                child: Row(
+                  children: [
+                    const Icon(CupertinoIcons.link,
+                        size: 16, color: ScribeColors.accentText),
+                    const SizedBox(width: 8),
+                    Text(linkName,
+                        style: ScribeType.body
+                            .copyWith(color: ScribeColors.accentText)),
+                  ],
+                ),
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+}

+ 161 - 0
mobile/lib/screens/resources_tab.dart

@@ -0,0 +1,161 @@
+import 'package:flutter/material.dart';
+
+import '../design.dart';
+import '../models/inventory.dart';
+import '../state/inventory_store.dart';
+import 'add_resource_screen.dart';
+import 'resource_detail_screen.dart';
+
+class ResourcesTab extends StatefulWidget {
+  const ResourcesTab({super.key, required this.store});
+
+  final InventoryStore store;
+
+  @override
+  State<ResourcesTab> createState() => _ResourcesTabState();
+}
+
+class _ResourcesTabState extends State<ResourcesTab> {
+  String _query = '';
+  String? _kind;
+
+  static const _kinds = [
+    'Server', 'Switch', 'Router', 'Firewall', 'AccessPoint', 'Desktop',
+    'Laptop', 'Ups', 'Other', 'System', 'Service',
+  ];
+
+  List<Resource> get _visible {
+    final all = widget.store.document?.resources ?? [];
+    final query = _query.trim().toLowerCase();
+    return all.where((r) {
+      if (_kind != null && r.kind != _kind) return false;
+      if (query.isEmpty) return true;
+      if (r.name.toLowerCase().contains(query)) return true;
+      if (r.tags.any((t) => t.toLowerCase().contains(query))) return true;
+      if (r.labels.entries
+          .any((e) => '${e.key}: ${e.value}'.toLowerCase().contains(query))) {
+        return true;
+      }
+      final ip = r.ip ?? r.network?.ip;
+      return ip != null && ip.toLowerCase().contains(query);
+    }).toList()
+      ..sort((a, b) {
+        final byKind = a.kind.compareTo(b.kind);
+        return byKind != 0 ? byKind : a.name.compareTo(b.name);
+      });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (widget.store.loadError != null) {
+      return ScribeSheet(
+        children: [
+          InlineNotice(
+            message: widget.store.loadError.toString(),
+            isError: true,
+            onRetry: widget.store.refresh,
+          ),
+        ],
+      );
+    }
+    if (!widget.store.loaded) return const ScribeSpinner();
+
+    return Scaffold(
+      floatingActionButton: FloatingActionButton.extended(
+        heroTag: 'add-resource',
+        backgroundColor: ScribeColors.accent,
+        onPressed: () => Navigator.of(context).push(MaterialPageRoute(
+          builder: (_) => AddResourceScreen(store: widget.store),
+        )),
+        icon: const Icon(Icons.add),
+        label: const Text('Add'),
+      ),
+      body: RefreshIndicator(
+        onRefresh: widget.store.refresh,
+        child: ScribeSheet(
+          children: [
+            TextField(
+              decoration: const InputDecoration(
+                hintText: 'Search names, IPs, tags, labels',
+                prefixIcon: Icon(Icons.search),
+              ),
+              onChanged: (value) => setState(() => _query = value),
+            ),
+            SizedBox(
+              height: ScribeSpacing.minTarget,
+              child: ListView(
+                scrollDirection: Axis.horizontal,
+                padding: EdgeInsets.zero,
+                children: [
+                  FilterPill(
+                    label: 'All',
+                    selected: _kind == null,
+                    onTap: () => setState(() => _kind = null),
+                  ),
+                  for (final kind in _kinds) ...[
+                    const SizedBox(width: 8),
+                    FilterPill(
+                      label: _plurals[kind] ?? kind,
+                      selected: _kind == kind,
+                      onTap: () => setState(() => _kind = kind),
+                    ),
+                  ],
+                ],
+              ),
+            ),
+            SurfacePanel(
+              padding: EdgeInsets.zero,
+              child: _list(context),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _list(BuildContext context) {
+    final visible = _visible;
+    if (visible.isEmpty) {
+      return const Padding(
+        padding: EdgeInsets.all(ScribeSpacing.panelPadding),
+        child: Text('Nothing matches.', style: ScribeType.metadata),
+      );
+    }
+
+    return Column(
+      children: [
+        for (var i = 0; i < visible.length; i++) ...[
+          if (i > 0)
+            const Divider(height: 1, thickness: 1, color: ScribeColors.separator),
+          ListTile(
+            contentPadding: const EdgeInsets.symmetric(
+                horizontal: ScribeSpacing.panelPadding, vertical: 4),
+            leading: CategoryMark(icon: visible[i].glyph),
+            title: Text(visible[i].name, style: ScribeType.title),
+            subtitle: Text(visible[i].subtitle, style: ScribeType.metadata),
+            trailing: const Icon(Icons.chevron_right,
+                color: ScribeColors.tertiaryText),
+            onTap: () => Navigator.of(context).push(MaterialPageRoute(
+              builder: (_) =>
+                  ResourceDetailScreen(store: widget.store, name: visible[i].name),
+            )),
+          ),
+        ],
+      ],
+    );
+  }
+}
+
+const _plurals = {
+  'Server': 'Servers',
+  'Switch': 'Switches',
+  'Router': 'Routers',
+  'Firewall': 'Firewalls',
+  'AccessPoint': 'APs',
+  'Desktop': 'Desktops',
+  'Laptop': 'Laptops',
+  'Ups': 'UPS',
+  'Other': 'Other',
+  'System': 'Systems',
+  'Service': 'Services',
+};

+ 125 - 0
mobile/lib/screens/summary_tab.dart

@@ -0,0 +1,125 @@
+import 'package:flutter/material.dart';
+
+import '../design.dart';
+import '../state/inventory_store.dart';
+
+class SummaryTab extends StatelessWidget {
+  const SummaryTab({super.key, required this.store});
+
+  final InventoryStore store;
+
+  @override
+  Widget build(BuildContext context) {
+    if (store.loadError != null) {
+      return ScribeSheet(
+        children: [
+          InlineNotice(
+            message: store.loadError.toString(),
+            isError: true,
+            onRetry: store.refresh,
+          ),
+        ],
+      );
+    }
+
+    final summary = store.summary;
+    if (summary == null) return const ScribeSpinner();
+
+    return RefreshIndicator(
+      onRefresh: store.refresh,
+      child: ScribeSheet(
+        children: [
+          const Text('Infrastructure', style: ScribeType.hero),
+          _countPanel('Hardware', summary['hardware'] as Map<String, dynamic>,
+              'totalHardware', 'hardwareByKind'),
+          _countPanel('Systems', summary['systems'] as Map<String, dynamic>,
+              'totalSystems', 'systemsByType'),
+          _servicesPanel(summary['services'] as Map<String, dynamic>),
+          _tagPanel('Tags', summary['tags'] as Map<String, dynamic>? ?? {}),
+          _tagPanel('Labels', summary['labels'] as Map<String, dynamic>? ?? {}),
+        ],
+      ),
+    );
+  }
+
+  Widget _countPanel(String title, Map<String, dynamic> section, String totalKey,
+      String byKey) {
+    final total = section[totalKey] as int? ?? 0;
+    final breakdown = (section[byKey] as Map<String, dynamic>? ?? {})
+        .map((k, v) => MapEntry(k, (v as num).toInt()));
+
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SectionLabel(title),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text('$total', style: ScribeType.section),
+              Text('$total total', style: ScribeType.metadata),
+            ],
+          ),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          Wrap(
+            spacing: ScribeSpacing.relatedGap,
+            runSpacing: 8,
+            children: [
+              for (final entry in breakdown.entries)
+                NoteFact(text: '${entry.value} ${entry.key}'),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _servicesPanel(Map<String, dynamic> services) {
+    return SurfacePanel(
+      child: Row(
+        children: [
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text('${services['totalServices'] ?? 0}', style: ScribeType.section),
+                const Text('services', style: ScribeType.metadata),
+              ],
+            ),
+          ),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text('${services['totalIpAddresses'] ?? 0}', style: ScribeType.section),
+                const Text('IP addresses', style: ScribeType.metadata),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _tagPanel(String title, Map<String, dynamic> counts) {
+    if (counts.isEmpty) return const SizedBox.shrink();
+    return SurfacePanel(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SectionLabel(title),
+          const SizedBox(height: ScribeSpacing.relatedGap),
+          Wrap(
+            spacing: ScribeSpacing.relatedGap,
+            runSpacing: 8,
+            children: [
+              for (final entry in counts.entries)
+                NoteFact(text: '${entry.key} (${entry.value})'),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 117 - 0
mobile/lib/screens/tree_tab.dart

@@ -0,0 +1,117 @@
+import 'package:flutter/material.dart';
+
+import '../design.dart';
+import '../models/inventory.dart';
+import '../state/inventory_store.dart';
+import 'resource_detail_screen.dart';
+
+class TreeTab extends StatelessWidget {
+  const TreeTab({super.key, required this.store});
+
+  final InventoryStore store;
+
+  @override
+  Widget build(BuildContext context) {
+    if (store.loadError != null) {
+      return ScribeSheet(
+        children: [
+          InlineNotice(
+            message: store.loadError.toString(),
+            isError: true,
+            onRetry: store.refresh,
+          ),
+        ],
+      );
+    }
+    final document = store.document;
+    if (document == null) return const ScribeSpinner();
+
+    final roots = document.resources
+        .where((r) => r.isHardware || (r.runsOn.isEmpty))
+        .toList()
+      ..sort((a, b) => a.name.compareTo(b.name));
+
+    if (roots.isEmpty) {
+      return const ScribeSpinner();
+    }
+
+    return RefreshIndicator(
+      onRefresh: store.refresh,
+      child: ScribeSheet(
+        children: [
+          const Text('Containment', style: ScribeType.hero),
+          SurfacePanel(
+            padding: const EdgeInsets.symmetric(
+                vertical: 8, horizontal: ScribeSpacing.panelPadding),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                for (final root in roots)
+                  _Branch(
+                    store: store,
+                    resource: root,
+                    depth: 0,
+                    ancestors: const {},
+                  ),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _Branch extends StatelessWidget {
+  const _Branch({
+    required this.store,
+    required this.resource,
+    required this.depth,
+    required this.ancestors,
+  });
+
+  final InventoryStore store;
+  final Resource resource;
+  final int depth;
+  final Set<String> ancestors;
+
+  @override
+  Widget build(BuildContext context) {
+    final nextAncestors = {...ancestors, resource.name.toLowerCase()};
+    final children = (store.document?.dependantsOf(resource.name) ?? [])
+        .where((c) => !nextAncestors.contains(c.name.toLowerCase()))
+        .toList()
+      ..sort((a, b) => a.name.compareTo(b.name));
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.stretch,
+      children: [
+        InkWell(
+          onTap: () => Navigator.of(context).push(MaterialPageRoute(
+            builder: (_) =>
+                ResourceDetailScreen(store: store, name: resource.name),
+          )),
+          child: Padding(
+            padding: EdgeInsets.only(left: depth * 16.0, top: 8, bottom: 8),
+            child: Row(
+              children: [
+                CategoryMark(icon: resource.glyph),
+                const SizedBox(width: 10),
+                Expanded(
+                  child: Text(resource.name, style: ScribeType.title),
+                ),
+              ],
+            ),
+          ),
+        ),
+        for (final child in children)
+          _Branch(
+            store: store,
+            resource: child,
+            depth: depth + 1,
+            ancestors: nextAncestors,
+          ),
+      ],
+    );
+  }
+}

+ 67 - 0
mobile/lib/state/inventory_store.dart

@@ -0,0 +1,67 @@
+import 'package:flutter/foundation.dart';
+
+import '../data/api_client.dart';
+import '../models/inventory.dart';
+
+/// Holds the fetched inventory document for the UI and performs writes.
+class InventoryStore extends ChangeNotifier {
+  InventoryStore(this.api);
+
+  final RackPeekApi api;
+
+  InventoryDocument? document;
+  Map<String, dynamic>? summary;
+  Object? loadError;
+  bool busy = false;
+
+  bool get loaded => document != null;
+
+  Future<void> refresh() async {
+    loadError = null;
+    try {
+      final results = await Future.wait([
+        api.getDocument(),
+        api.getSummary(),
+      ]);
+      document = results[0] as InventoryDocument;
+      summary = results[1] as Map<String, dynamic>;
+    } catch (e) {
+      loadError = e;
+    }
+    notifyListeners();
+  }
+
+  /// Sends a partial update (Merge) and refetches. Returns the server's
+  /// diff summary, or throws [ApiException].
+  Future<Map<String, dynamic>> applyPatch(Map<String, dynamic> patch) async {
+    busy = true;
+    notifyListeners();
+    try {
+      final result = await api.upsert({
+        'version': document?.version ?? 4,
+        'resources': [patch],
+      });
+      await refresh();
+      return result;
+    } finally {
+      busy = false;
+      notifyListeners();
+    }
+  }
+
+  Future<Map<String, dynamic>> addResource(Map<String, dynamic> resource) async {
+    busy = true;
+    notifyListeners();
+    try {
+      final result = await api.upsert({
+        'version': document?.version ?? 4,
+        'resources': [resource],
+      });
+      await refresh();
+      return result;
+    } finally {
+      busy = false;
+      notifyListeners();
+    }
+  }
+}

+ 48 - 0
mobile/privacy-policy.md

@@ -0,0 +1,48 @@
+# RackPeek for iOS Privacy Policy
+
+**Effective date: 22 October 2026**
+
+RackPeek for iOS is a companion app for a RackPeek server that you choose and
+operate. This policy describes the app's current data handling.
+
+## Information the app uses
+
+To connect, you enter a RackPeek server address and API key. The app stores
+these values in iOS secure storage on your device so it can reconnect. The app
+sends the API key with requests to the server address you entered. Inventory
+information is received from that server and displayed in the app. Changes you
+make are sent back to that server.
+
+The RackPeek app publisher does not receive this connection information or
+inventory through an app-operated service. Your RackPeek server may keep its own
+logs or inventory data under the configuration and policies set by its
+operator. Contact that operator with questions about their server.
+
+## Network security
+
+Use HTTPS when your server supports it. If you connect over unencrypted HTTP,
+the API key and inventory traffic may be visible to other systems on that
+network. Only connect to a server you trust.
+
+## Analytics and tracking
+
+The current app source does not include advertising, analytics, or cross-app
+tracking features. This policy must be reviewed if third-party SDKs or new
+data-handling features are added.
+
+## Your choices
+
+You can disconnect from the server in the app's settings. Disconnecting removes
+the saved server address and API key from this device. It does not delete data
+from your RackPeek server; manage server data with the server's own tools.
+
+## Children's privacy
+
+RackPeek is a general-purpose infrastructure inventory tool and is not designed
+for children.
+
+## Changes and contact
+
+This policy may be updated when app behavior changes. For privacy questions,
+contact the app's publisher through the project's public support page:
+https://github.com/Timmoth/RackPeek/issues

+ 498 - 0
mobile/pubspec.lock

@@ -0,0 +1,498 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  args:
+    dependency: transitive
+    description:
+      name: args
+      sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.7.0"
+  async:
+    dependency: transitive
+    description:
+      name: async
+      sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.13.1"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  characters:
+    dependency: transitive
+    description:
+      name: characters
+      sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.1"
+  clock:
+    dependency: transitive
+    description:
+      name: clock
+      sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.2"
+  code_assets:
+    dependency: transitive
+    description:
+      name: code_assets
+      sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.19.1"
+  crypto:
+    dependency: transitive
+    description:
+      name: crypto
+      sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.7"
+  cupertino_icons:
+    dependency: "direct main"
+    description:
+      name: cupertino_icons
+      sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.9"
+  fake_async:
+    dependency: transitive
+    description:
+      name: fake_async
+      sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.3"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.0"
+  ffi_leak_tracker:
+    dependency: transitive
+    description:
+      name: ffi_leak_tracker
+      sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.2"
+  flutter:
+    dependency: "direct main"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_lints:
+    dependency: "direct dev"
+    description:
+      name: flutter_lints
+      sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.0"
+  flutter_secure_storage:
+    dependency: "direct main"
+    description:
+      name: flutter_secure_storage
+      sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8
+      url: "https://pub.dev"
+    source: hosted
+    version: "11.2.0"
+  flutter_secure_storage_darwin:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_darwin
+      sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.4.3"
+  flutter_secure_storage_linux:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_linux
+      sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.3"
+  flutter_secure_storage_platform_interface:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_platform_interface
+      sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  flutter_secure_storage_web:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_web
+      sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  flutter_secure_storage_windows:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_windows
+      sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.2.2"
+  flutter_test:
+    dependency: "direct dev"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  hooks:
+    dependency: transitive
+    description:
+      name: hooks
+      sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.2"
+  http:
+    dependency: "direct main"
+    description:
+      name: http
+      sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.6.0"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.1.2"
+  jni:
+    dependency: transitive
+    description:
+      name: jni
+      sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  jni_flutter:
+    dependency: transitive
+    description:
+      name: jni_flutter
+      sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.4+1"
+  jni_util:
+    dependency: transitive
+    description:
+      name: jni_util
+      sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0"
+  leak_tracker:
+    dependency: transitive
+    description:
+      name: leak_tracker
+      sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+      url: "https://pub.dev"
+    source: hosted
+    version: "11.0.2"
+  leak_tracker_flutter_testing:
+    dependency: transitive
+    description:
+      name: leak_tracker_flutter_testing
+      sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.10"
+  leak_tracker_testing:
+    dependency: transitive
+    description:
+      name: leak_tracker_testing
+      sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  lints:
+    dependency: transitive
+    description:
+      name: lints
+      sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.1.0"
+  logging:
+    dependency: transitive
+    description:
+      name: logging
+      sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
+  matcher:
+    dependency: transitive
+    description:
+      name: matcher
+      sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.12.18"
+  material_color_utilities:
+    dependency: transitive
+    description:
+      name: material_color_utilities
+      sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.13.0"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.17.0"
+  objective_c:
+    dependency: transitive
+    description:
+      name: objective_c
+      sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
+      url: "https://pub.dev"
+    source: hosted
+    version: "9.5.0"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.1"
+  path_provider:
+    dependency: transitive
+    description:
+      name: path_provider
+      sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.6"
+  path_provider_android:
+    dependency: transitive
+    description:
+      name: path_provider_android
+      sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.1"
+  path_provider_foundation:
+    dependency: transitive
+    description:
+      name: path_provider_foundation
+      sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.6.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.2"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.3"
+  path_provider_windows:
+    dependency: transitive
+    description:
+      name: path_provider_windows
+      sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.0"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.6"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.8"
+  pub_semver:
+    dependency: transitive
+    description:
+      name: pub_semver
+      sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.1"
+  record_use:
+    dependency: transitive
+    description:
+      name: record_use
+      sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.0"
+  sky_engine:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.10.2"
+  stack_trace:
+    dependency: transitive
+    description:
+      name: stack_trace
+      sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.12.1"
+  stream_channel:
+    dependency: transitive
+    description:
+      name: stream_channel
+      sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.1"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.2"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.9"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.0"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.0"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
+      url: "https://pub.dev"
+    source: hosted
+    version: "15.3.0"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.4.0"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  yaml:
+    dependency: transitive
+    description:
+      name: yaml
+      sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.4"
+sdks:
+  dart: ">=3.11.1 <4.0.0"
+  flutter: ">=3.38.4"

+ 91 - 0
mobile/pubspec.yaml

@@ -0,0 +1,91 @@
+name: rackpeek_mobile
+description: "A new Flutter project."
+# The following line prevents the package from being accidentally published to
+# pub.dev using `flutter pub publish`. This is preferred for private packages.
+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
+
+# The following defines the version and build number for your application.
+# A version number is three numbers separated by dots, like 1.2.43
+# followed by an optional build number separated by a +.
+# Both the version and the builder number may be overridden in flutter
+# build by specifying --build-name and --build-number, respectively.
+# In Android, build-name is used as versionName while build-number used as versionCode.
+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
+# Read more about iOS versioning at
+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
+# In Windows, build-name is used as the major, minor, and patch parts
+# of the product and file versions while build-number is used as the build suffix.
+version: 1.0.0+1
+
+environment:
+  sdk: ^3.11.1
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+  flutter:
+    sdk: flutter
+
+  # The following adds the Cupertino Icons font to your application.
+  # Use with the CupertinoIcons class for iOS style icons.
+  cupertino_icons: ^1.0.8
+  http: ^1.6.0
+  flutter_secure_storage: ^11.2.0
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+
+  # The "flutter_lints" package below contains a set of recommended lints to
+  # encourage good coding practices. The lint set provided by the package is
+  # activated in the `analysis_options.yaml` file located at the root of your
+  # package. See that file for information about deactivating specific lint
+  # rules and activating additional ones.
+  flutter_lints: ^6.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+
+  # The following line ensures that the Material Icons font is
+  # included with your application, so that you can use the icons in
+  # the material Icons class.
+  uses-material-design: true
+
+  # To add assets to your application, add an assets section, like this:
+  # assets:
+  #   - images/a_dot_burr.jpeg
+  #   - images/a_dot_ham.jpeg
+
+  # An image asset can refer to one or more resolution-specific "variants", see
+  # https://flutter.dev/to/resolution-aware-images
+
+  # For details regarding adding assets from package dependencies, see
+  # https://flutter.dev/to/asset-from-package
+
+  # To add custom fonts to your application, add a fonts section here,
+  # in this "flutter" section. Each entry in this list should have a
+  # "family" key with the font family name, and a "fonts" key with a
+  # list giving the asset and other descriptors for the font. For
+  # example:
+  # fonts:
+  #   - family: Schyler
+  #     fonts:
+  #       - asset: fonts/Schyler-Regular.ttf
+  #       - asset: fonts/Schyler-Italic.ttf
+  #         style: italic
+  #   - family: Trajan Pro
+  #     fonts:
+  #       - asset: fonts/TrajanPro.ttf
+  #       - asset: fonts/TrajanPro_Bold.ttf
+  #         weight: 700
+  #
+  # For details regarding fonts from package dependencies,
+  # see https://flutter.dev/to/font-from-package

BIN
mobile/store-assets.zip


+ 41 - 0
mobile/store-assets/README.md

@@ -0,0 +1,41 @@
+# App Store assets
+
+Upload the JPEG screenshots in each device folder to the matching screenshot
+group in App Store Connect. Keep each folder's numerical order when uploading.
+
+| Folder | Capture device | Image size | Purpose |
+|---|---|---:|---|
+| `ios/iphone-6.3/` | iPhone 17 Pro | 1206 × 2622 | Required medium Dynamic Island iPhone group |
+| `ios/iphone-6.9/` | iPhone 17 Pro Max | 1320 × 2868 | Large iPhone group |
+| `ios/ipad-13/` | iPad Pro 13-inch (M5) | 2064 × 2752 | Required iPad group |
+
+All screenshots are direct captures of the running app. The inventory is
+fictional and uses reserved `example.test` service domains. The status bar is
+set to 9:41. The screenshots are JPEGs without alpha channels.
+
+`ios/app-icon-1024.png` is the 1024 × 1024 App Store icon from the iOS asset
+catalog. It is already embedded in the app build; this copy is for reference.
+
+## Product page creative
+
+`ios/creative-assets/universal-5244x2950.png` is the universal creative asset
+for the product page header and Search Results. It is a PNG at Apple's universal
+asset size. `ios/creative-assets/header-3840x1646.jpg` is the alternate wide
+header version. Use the universal asset first where App Store Connect offers
+the same creative for both placements.
+
+The App Store preview asset is a video slot and remains optional. The supplied
+screenshots are ready to upload; they do not need a separate preview video.
+
+## Screenshot order
+
+1. `01-overview` — infrastructure summary
+2. `02-inventory` — searchable resource list
+3. `03-resource-detail` — hardware details and dependent systems
+4. `04-edit-resource` — resource editor (iPhone only)
+5. `05-service-detail` — service address and host relationship (large iPhone)
+6. `06-containment-tree` — infrastructure relationships (large iPhone)
+
+The medium iPhone set includes overview, inventory, resource detail, and
+containment tree. The iPad set includes overview, inventory, and containment
+tree.

BIN
mobile/store-assets/ios/app-icon-1024.png


BIN
mobile/store-assets/ios/creative-assets/header-3840x1646.jpg


BIN
mobile/store-assets/ios/creative-assets/universal-5244x2950.png


BIN
mobile/store-assets/ios/creative-assets/working/brand-background.png


Разница между файлами не показана из-за своего большого размера
+ 23 - 0
mobile/store-assets/ios/creative-assets/working/header.svg


Разница между файлами не показана из-за своего большого размера
+ 23 - 0
mobile/store-assets/ios/creative-assets/working/universal.svg


BIN
mobile/store-assets/ios/ipad-13/01-overview.jpg


BIN
mobile/store-assets/ios/ipad-13/02-inventory.jpg


Некоторые файлы не были показаны из-за большого количества измененных файлов