diff --git a/AGENTS.md b/AGENTS.md index 7cf944c8..2a1fd5dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ local mode - the shipped behavior: signed out, library on disk, exactly what mai cloud mode - signed in, library synced to Convex. op - one queued change to cloud data. an op lands when the server accepts it. -The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md defines how the app must look and how we build UI, read it before touching UI. PRODUCT.md holds who this is for and how it must feel. +The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md holds the rules for how we build UI (the values themselves live in `lib/const/settings.dart`), read it before touching UI. PRODUCT.md holds who this is for and how it must feel. Here's the philosophy we work by: diff --git a/DESIGN.md b/DESIGN.md index 8f61c07f..4d2b7423 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,165 +1,38 @@ ---- -name: Icarus -description: A refined tactical desktop workspace for Valorant strategy planning. -colors: - tactical-background: "#09090b" - tactical-sidebar: "#141114" - tactical-card: "#18181b" - tactical-panel: "#1b1b1b" - tactical-raised: "#27272a" - tactical-border: "#27272a" - tactical-scrollbar: "#353435" - tactical-foreground: "#fafafa" - tactical-muted: "#a1a1aa" - tactical-primary: "#7c3aed" - tactical-primary-deep: "#4c1d95" - tactical-primary-foreground: "#f9fafb" - tactical-danger: "#ef4444" - tactical-favorite: "#ff9800" - tactical-favorite-danger: "#e53935" - tactical-ally: "#3a7e5d" - tactical-ally-outline: "#69f0af6a" - tactical-enemy: "#772727" - tactical-enemy-outline: "#ff52528b" - map-base: "#271406" - map-detail: "#b27c40" - map-highlight: "#f08234" -typography: - headline: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "20px" - fontWeight: 500 - lineHeight: 1.2 - letterSpacing: "normal" - title: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "16px" - fontWeight: 600 - lineHeight: 1.25 - letterSpacing: "normal" - body: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "14px" - fontWeight: 400 - lineHeight: 1.35 - letterSpacing: "normal" - label: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "12px" - fontWeight: 600 - lineHeight: 1.2 - letterSpacing: "0.3px" - micro: - fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" - fontSize: "10px" - fontWeight: 600 - lineHeight: 1.2 - letterSpacing: "0.5px" -rounded: - xs: "3px" - sm: "4px" - md: "6px" - lg: "8px" - xl: "10px" - panel: "12px" - card: "16px" - dialog: "22px" - pill: "22px" -spacing: - xxs: "2px" - xs: "4px" - sm: "6px" - md: "8px" - lg: "10px" - xl: "12px" - section: "16px" - panel: "24px" - grid-gap: "20px" -components: - button-primary: - backgroundColor: "{colors.tactical-primary}" - textColor: "{colors.tactical-primary-foreground}" - rounded: "{rounded.lg}" - padding: "8px 14px" - button-secondary: - backgroundColor: "{colors.tactical-raised}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.lg}" - padding: "8px 14px" - icon-button: - backgroundColor: "{colors.tactical-raised}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.lg}" - size: "40px" - tool-button-selected: - backgroundColor: "{colors.tactical-primary}" - textColor: "{colors.tactical-primary-foreground}" - rounded: "{rounded.lg}" - size: "57.8px" - search-field: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.lg}" - height: "40px" - segmented-tabs: - backgroundColor: "{colors.tactical-raised}" - textColor: "{colors.tactical-muted}" - rounded: "{rounded.md}" - padding: "2px" - strategy-card: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.card}" - padding: "8px" - sidebar-panel: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.panel}" - width: "345px" - title-strip: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - height: "40px" - controlHeight: "28px" - folder-card: - backgroundColor: "{colors.tactical-card}" - textColor: "{colors.tactical-foreground}" - rounded: "{rounded.panel}" - size: "232px x 64px" ---- - # Design: Icarus Icarus is a tactical workbench: dark, dense, map-first. The canvas and the tactical objects on it stay visually dominant; everything else is hardware around the bench. Polish comes from order, not ornament. +The palette, theme, and sizing constants live in `lib/const/settings.dart`, with `Settings.tacticalVioletTheme` as the ShadColorScheme. That file is the only source of truth for values. This file holds the rules for using them. + ## Building UI -- shadcn_ui is the component library. Reach for `Shad*` widgets first — `ShadDialog`, `ShadButton`, `ShadIconButton`, `ShadInput`, `ShadSelect`, `ShadTooltip`, `ShadContextMenu*`, `ShadPopover` — before Material equivalents or custom widgets. Read theme values through `ShadTheme.of(context)`. -- `ShadDialog` does not provide a `Material` ancestor. Material-dependent children (`TextField`, `InkWell`, `LinearProgressIndicator`, `Slider`) throw "No Material widget found" inside one. Wrap the dialog's `child` in `Material(color: Colors.transparent, child: ...)` — see `lib/widgets/dialogs/export_video_dialog.dart` for the idiom. -- Color, theme, and sizing constants live in `lib/const/settings.dart` (including the `tacticalVioletTheme` ShadColorScheme). Use them; never hardcode a hex that already has a name. The frontmatter above mirrors these values for reference. -- Spacing uses the 8/10/12/16/24px steps from the frontmatter. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs. +- shadcn_ui is the component library. Reach for `Shad*` widgets first (`ShadDialog`, `ShadButton`, `ShadIconButton`, `ShadInput`, `ShadSelect`, `ShadTooltip`, `ShadContextMenu*`, `ShadPopover`) before Material equivalents or custom widgets. Read theme values through `ShadTheme.of(context)`. +- `ShadDialog` does not provide a `Material` ancestor. Material-dependent children (`TextField`, `InkWell`, `LinearProgressIndicator`, `Slider`) throw "No Material widget found" inside one. Wrap the dialog's `child` in `Material(color: Colors.transparent, child: ...)`; see `lib/widgets/dialogs/export_video_dialog.dart` for the idiom. +- Never hardcode a hex that already has a name in `lib/const/settings.dart`. If a color is new, name it there first. +- Spacing steps are 8/10/12/16/24px. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs. +- Type roles, all in the system sans stack: headline 20px/500, title 16px/600, body 14px/400, label 12px/600, micro 10px/600. Hierarchy comes from these five roles, not from display fonts or hero-scale type. - Transitions run 150-250ms and must communicate a state change (hover, selection, reveal, loading). No motion for its own sake. ## Window chrome - Desktop builds hide the native title bar. Each top-level screen draws its own 40px strip (`lib/widgets/window_chrome.dart`): macOS keeps its traffic lights, so the strip leaves a 78px inset on the left; Windows and Linux get app-drawn caption buttons on the right; the strip is the drag handle. Web renders the same strip with no inset and no buttons. - The library strip holds the three tabs on the left and only search, sort, New, and the account on the right. Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. +- The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). For a cloud strategy the save button's glyph is the sync state; the words live in its tooltip and a popover opens only when sync needs attention. No status chips or labels in the editor. -## Named rules +## Icons -**The One Command Color Rule.** Violet marks current action, selection, focus, and primary commands — nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. +- Lucide is the icon family (`LucideIcons.*`, re-exported by shadcn_ui). Material `Icons.*` only survive in the persisted folder-icon registry and cursor glyphs. Toolbar and tool glyphs are 18-22px, menu and inline glyphs 16px. +- Icons rest in muted foreground and come up to foreground on hover. Violet on an icon means it is the selected or checked item, nothing else. -**The Tactical Semantics Rule.** Ally green, enemy red, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. +## Things I would like to remain consistent -**The Tonal First Rule.** Depth comes from surface steps (background → panel → raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). +**The One Command Color** Violet marks current action, selection, focus, and primary commands, and nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. -**The Native Tool Rule.** System sans stack for everything. Hierarchy comes from the five frontmatter type roles (headline/title/body/label/micro), not from display fonts or hero-scale type. +**The Tactical Semantics** Ally green, enemy red, defender blue, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. -**Every control earns its position.** If you can't say why a control sits where it sits, it isn't done. Never fill spare space with a feature. +**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). A selected or primary state is never a flat fill: it is a raised surface, lit from above. The fill runs lighter at the top, a 1px light sits inside the top edge, a 1px shade inside the bottom, and a 1px shadow drops beneath; the sides stay bare. `Settings.raised(color, radius)` builds it for any base color (`raisedPrimary` and `raisedSurface` are the violet and zinc shortcuts), painted by `InsetShadowDecoration` (`lib/widgets/inset_shadow_decoration.dart`), which also tweens in animated containers. Primary buttons get it from the Shad theme. Hover stays flat. -## Don't +**Every control earns its position.** If you can't say why a control sits where it sits, it isn't done. Never fill spare space with a feature. -- No gradients, glow, glassmorphism, or decorative effects — the anti-reference is the generic gamer overlay. -- No marketing-page composition inside the product: no hero typography, no decorative dashboards. -- No colored side-stripe borders, gradient text, or nested cards. -- No custom affordance where a standard Shad or desktop pattern already communicates the action. +## Some general rules +These steer us in the right direction. They are not hard-set, but default to following them; if you think one should be ignored, be very loud about it and get approval from us first. diff --git a/README.md b/README.md index 4691478d..c765a551 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,31 @@ This rewrites the current user's `icarus://` handler to the debug executable. After testing OAuth, launch the installed Icarus app once to restore the handler back to the installed build. +### Isolated Hive store + +Desktop builds accept an absolute `--hive-store-dir` path. It moves every +Hive box, including the library, preferences, and anonymous analytics, without +touching the normal Hive files. + +Pass the option through Flutter with one Dart entrypoint argument: + +```bash +fvm flutter run -d macos -a "--hive-store-dir=/Users/your-name/Library/Containers/xyz.icarus-strats/Data/Library/Application Support/xyz.icarus-strats-demo" +``` + +The macOS app sandbox limits this path to Icarus's container unless the user +selects another directory through a native picker. The container lives at +`~/Library/Containers/xyz.icarus-strats/Data`. + +Or pass it directly to a built executable: + +```bash +./icarus --hive-store-dir "/absolute/path/to/icarus-demo-hive" +``` + +Strategy media, debug logs, and WebView data still use the normal application +support directory. Use a different absolute Hive directory for each instance. + ## Build ```bash diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 00ff5ebe..ff2dbf2a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -25,6 +25,7 @@ import type * as lib_payloadValidators from "../lib/payloadValidators.js"; import type * as lib_publicValidators from "../lib/publicValidators.js"; import type * as lib_r2 from "../lib/r2.js"; import type * as lib_snapshotSerialization from "../lib/snapshotSerialization.js"; +import type * as lib_strategyAgentSummary from "../lib/strategyAgentSummary.js"; import type * as lineups from "../lineups.js"; import type * as maintenance from "../maintenance.js"; import type * as ops from "../ops.js"; @@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{ "lib/publicValidators": typeof lib_publicValidators; "lib/r2": typeof lib_r2; "lib/snapshotSerialization": typeof lib_snapshotSerialization; + "lib/strategyAgentSummary": typeof lib_strategyAgentSummary; lineups: typeof lineups; maintenance: typeof maintenance; ops: typeof ops; diff --git a/convex/folders.test.ts b/convex/folders.test.ts index 87e3a331..72b9d079 100644 --- a/convex/folders.test.ts +++ b/convex/folders.test.ts @@ -100,3 +100,108 @@ test("folder move rejects a descendant parent without changing the tree", async await expect(parentPublicId(t, "child")).resolves.toBe("root"); await expect(parentPublicId(t, "grandchild")).resolves.toBe("child"); }); + +const listFolderTree = makeFunctionReference<"query">("folders:listTree"); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); + +async function seedStrategy( + owner: Harness, + publicId: string, + folderPublicId: string, + mapData: string, +) { + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId, + name: publicId, + mapData, + folderPublicId, + initialPagePublicId: `${publicId}-page`, + initialPageName: "Page 1", + initialPageIsAttack: true, + initialPageSettings: { + agentSize: 48, + abilitySize: 32, + useNeutralTeamColors: false, + }, + }); +} + +async function placeAgent( + owner: Harness, + strategyPublicId: string, + elementPublicId: string, + agentType: string, +) { + await owner.mutation(applyBatch, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + clientId: "client-a", + ops: [ + { + opId: `${elementPublicId}-add`, + type: "element.add", + elementPublicId, + pagePublicId: `${strategyPublicId}-page`, + payload: { + kind: "agent", + payloadVersion: 1, + data: { type: agentType, position: { x: 0.5, y: 0.5 } }, + }, + sortIndex: 0, + }, + ], + }); +} + +test("folder tree summarises strategies, maps, and agents across the subtree", async () => { + const { owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedFolder(owner, "child", "root"); + await seedStrategy(owner, "s-root", "root", "ascent"); + await seedStrategy(owner, "s-child-1", "child", "haven"); + await seedStrategy(owner, "s-child-2", "child", "haven"); + await placeAgent(owner, "s-root", "e1", "jett"); + await placeAgent(owner, "s-child-1", "e2", "sova"); + await placeAgent(owner, "s-child-2", "e3", "sova"); + + const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array< + Record + >; + const byId = new Map(tree.map((entry) => [entry.publicId, entry])); + expect(byId.get("child")).toMatchObject({ + strategyCount: 2, + mapPeeks: ["haven"], + agentTypes: ["sova"], + }); + expect(byId.get("root")).toMatchObject({ + strategyCount: 3, + mapPeeks: ["haven", "ascent"], + agentTypes: ["sova", "jett"], + }); +}); + +test("deleting a strategy drops it from the folder summary", async () => { + const { owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedStrategy(owner, "s-root", "root", "ascent"); + await placeAgent(owner, "s-root", "e1", "jett"); + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: "s-root", + expectedRevision: 0, + }); + + const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array< + Record + >; + expect(tree[0]).toMatchObject({ + strategyCount: 0, + mapPeeks: [], + agentTypes: [], + }); +}); diff --git a/convex/folders.ts b/convex/folders.ts index d6386902..14c0758b 100644 --- a/convex/folders.ts +++ b/convex/folders.ts @@ -8,6 +8,7 @@ import { requireCurrentUser, } from "./lib/auth"; import { getFolderByPublicId } from "./lib/entities"; +import { readStrategyAgentTypes } from "./lib/strategyAgentSummary"; import { assertSupportedCloudProtocol, cloudProtocolArgs, @@ -285,6 +286,10 @@ export const listTree = query({ const folderLookup = new Map( accessible.map(({ folder }) => [folder._id, folder]), ); + const summaries = await summariseFolderTrees( + ctx, + accessible.map(({ folder }) => folder), + ); return accessible .sort((a, b) => a.folder.createdAt - b.folder.createdAt) @@ -304,10 +309,95 @@ export const listTree = query({ createdAt: folder.createdAt, updatedAt: folder.updatedAt, role, + ...summaries.get(folder._id)!, })); }, }); +type FolderTreeSummary = { + strategyCount: number; + mapPeeks: string[]; + agentTypes: string[]; +}; + +/// One pass over the strategies of every listed folder, then counts roll up +/// from each folder into its ancestors so a parent summarises its whole +/// subtree. Folders outside the list (not accessible) contribute nothing. +async function summariseFolderTrees( + ctx: AnyCtx, + folders: Doc<"folders">[], +): Promise, FolderTreeSummary>> { + const listed = new Set(folders.map((folder) => folder._id)); + const strategyCounts = new Map, number>(); + const mapCounts = new Map, Map>(); + const agentCounts = new Map, Map>(); + for (const folder of folders) { + strategyCounts.set(folder._id, 0); + mapCounts.set(folder._id, new Map()); + agentCounts.set(folder._id, new Map()); + } + const bump = (counts: Map, key: string) => + counts.set(key, (counts.get(key) ?? 0) + 1); + + for (const folder of folders) { + const strategies = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + const agentTypesSeen = new Map(); + for (const strategy of strategies) { + bump(mapCounts.get(folder._id)!, strategy.mapData); + for (const type of await readStrategyAgentTypes(ctx, strategy._id)) { + bump(agentTypesSeen, type); + } + } + strategyCounts.set(folder._id, strategies.length); + agentCounts.set(folder._id, agentTypesSeen); + } + + // Roll every folder's own counts up through its listed ancestors. + const merged = new Map, FolderTreeSummary>(); + const totals = new Map< + Id<"folders">, + { strategies: number; maps: Map; agents: Map } + >(); + for (const folder of folders) { + totals.set(folder._id, { strategies: 0, maps: new Map(), agents: new Map() }); + } + const mergeCounts = (into: Map, from: Map) => { + for (const [key, count] of from) into.set(key, (into.get(key) ?? 0) + count); + }; + for (const folder of folders) { + let current: Doc<"folders"> | undefined = folder; + const visited = new Set>(); + while (current !== undefined && !visited.has(current._id)) { + visited.add(current._id); + const total = totals.get(current._id)!; + total.strategies += strategyCounts.get(folder._id)!; + mergeCounts(total.maps, mapCounts.get(folder._id)!); + mergeCounts(total.agents, agentCounts.get(folder._id)!); + const parentId: Id<"folders"> | undefined = current.parentFolderId; + current = + parentId !== undefined && listed.has(parentId) + ? folders.find((candidate) => candidate._id === parentId) + : undefined; + } + } + const ranked = (counts: Map) => + [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([key]) => key); + for (const folder of folders) { + const total = totals.get(folder._id)!; + merged.set(folder._id, { + strategyCount: total.strategies, + mapPeeks: ranked(total.maps).slice(0, 2), + agentTypes: ranked(total.agents), + }); + } + return merged; +} + export const move = mutation({ args: { ...cloudProtocolArgs, diff --git a/convex/function_spec.json b/convex/function_spec.json index d342d722..946abb2a 100644 --- a/convex/function_spec.json +++ b/convex/function_spec.json @@ -39531,6 +39531,15 @@ "value": { "type": "object", "value": { + "agentTypes": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, "color": { "fieldType": { "type": "union", @@ -39621,6 +39630,15 @@ }, "optional": false }, + "mapPeeks": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, "name": { "fieldType": { "type": "string" @@ -39667,6 +39685,12 @@ }, "optional": false }, + "strategyCount": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "updatedAt": { "fieldType": { "type": "number" @@ -47916,6 +47940,20 @@ "kind": "public" } }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "maintenance.js:backfillStrategyAgentSummaries", + "returns": { + "type": "number" + }, + "visibility": { + "kind": "internal" + } + }, { "args": { "type": "object", diff --git a/convex/lib/publicValidators.ts b/convex/lib/publicValidators.ts index f85e7daf..ceea6363 100644 --- a/convex/lib/publicValidators.ts +++ b/convex/lib/publicValidators.ts @@ -78,6 +78,12 @@ export const folderSummaryValidator = v.object({ createdAt: v.number(), updatedAt: v.number(), role: accessRoleValidator, + // What the folder holds, including every subfolder: how many strategies, + // the two most-used maps (wire names, most used first), and the agents in + // play ordered by how many strategies use them. + strategyCount: v.number(), + mapPeeks: v.array(v.string()), + agentTypes: v.array(v.string()), }); export const pageDescriptorValidator = v.object({ diff --git a/convex/lib/strategyAgentSummary.ts b/convex/lib/strategyAgentSummary.ts new file mode 100644 index 00000000..8123f016 --- /dev/null +++ b/convex/lib/strategyAgentSummary.ts @@ -0,0 +1,94 @@ +import type { Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; + +/// Reads the agent type of one agent element or lineup group payload. The +/// client stores the agent enum name under `type` (an element) or under +/// `agent.type` (a lineup group); anything else is not an agent. +function agentTypeOf(data: unknown): string | null { + if (typeof data !== "object" || data === null) return null; + const record = data as Record; + const direct = record.type; + if (typeof direct === "string" && direct.length > 0) return direct; + const agent = record.agent; + if (typeof agent === "object" && agent !== null) { + const nested = (agent as Record).type; + if (typeof nested === "string" && nested.length > 0) return nested; + } + return null; +} + +/// Recomputes which agents a strategy uses, from its live agent elements and +/// lineup groups, and stores the answer in its own row. Content ops never +/// touch the strategy row itself; the summary is derived data that the +/// folder tree reads without scanning elements. +export async function refreshStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const counts = new Map(); + const bump = (type: string | null) => { + if (type === null) return; + counts.set(type, (counts.get(type) ?? 0) + 1); + }; + const elements = await ctx.db + .query("elements") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + for (const element of elements) { + if (element.deleted || element.payload.kind !== "agent") continue; + bump(agentTypeOf(element.payload.data)); + } + const lineups = await ctx.db + .query("lineups") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + for (const lineup of lineups) { + if (lineup.deleted) continue; + bump(agentTypeOf(lineup.payload.data)); + } + const agentTypes = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([type]) => type); + + const existing = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + const now = Date.now(); + if (existing === null) { + if (agentTypes.length === 0) return; + await ctx.db.insert("strategyAgentSummaries", { + strategyId, + agentTypes, + updatedAt: now, + }); + return; + } + const unchanged = + existing.agentTypes.length === agentTypes.length && + existing.agentTypes.every((type, index) => type === agentTypes[index]); + if (unchanged) return; + await ctx.db.patch(existing._id, { agentTypes, updatedAt: now }); +} + +export async function deleteStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const existing = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + if (existing !== null) await ctx.db.delete(existing._id); +} + +export async function readStrategyAgentTypes( + ctx: QueryCtx | MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const summary = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + return summary?.agentTypes ?? []; +} diff --git a/convex/maintenance.ts b/convex/maintenance.ts index c27839a0..55a14073 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -6,6 +6,7 @@ import { collectAssetIdsFromLineupPayload, } from "./lib/imageAssets"; import { captureDeletedPageImageAssets } from "./images"; +import { refreshStrategyAgentSummary } from "./lib/strategyAgentSummary"; const MAINTENANCE_BATCH_SIZE = 200; const DAYS_30_MS = 30 * 24 * 60 * 60 * 1000; @@ -140,3 +141,17 @@ export const purgeOldTombstones = internalMutation({ } }, }); + +/// One-off after the agent summary table shipped: every strategy written +/// before it needs its summary computed once. Safe to re-run. +export const backfillStrategyAgentSummaries = internalMutation({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const strategies = await ctx.db.query("strategies").collect(); + for (const strategy of strategies) { + await refreshStrategyAgentSummary(ctx, strategy._id); + } + return strategies.length; + }, +}); diff --git a/convex/ops.ts b/convex/ops.ts index 5de8952e..70bbd8e0 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -2,6 +2,7 @@ import { mutation, type MutationCtx } from "./_generated/server"; import { ConvexError, v, type Infer } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { assertStrategyRole } from "./lib/auth"; +import { refreshStrategyAgentSummary } from "./lib/strategyAgentSummary"; import { clampPageIndex, getStrategyByPublicId, @@ -1323,6 +1324,7 @@ export const applyBatch = mutation({ await assertStrategyRole(ctx, strategy, "editor"); const results: PublicOperationResult[] = []; let acceptedStrategyBatchBaseRevision: number | undefined; + let contentChanged = false; // Outcomes are per operation: accepted changes and visible rejections are // committed together by this single Convex transaction. One stale op must @@ -1401,6 +1403,9 @@ export const applyBatch = mutation({ } else { result = await applyLineupOp(ctx, strategy, op); } + if (result.status === "ack" && op.entityType !== "strategy") { + contentChanged = true; + } } catch (error) { if (!(error instanceof ConvexError)) throw error; const rawCode = @@ -1458,6 +1463,10 @@ export const applyBatch = mutation({ results.push(publicResult); } + if (contentChanged) { + await refreshStrategyAgentSummary(ctx, strategy._id); + } + return { strategyPublicId: strategy.publicId, results }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 47eb17f7..b9908d50 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -200,6 +200,13 @@ export default defineSchema({ .index("by_uploadStatus_and_updatedAt", ["uploadStatus", "updatedAt"]) .index("by_storageId", ["storageId"]) .index("by_objectKey", ["objectKey"]), + // Derived: which agents each strategy uses, kept current by ops.applyBatch + // so the folder tree can summarise a folder without reading its elements. + strategyAgentSummaries: defineTable({ + strategyId: v.id("strategies"), + agentTypes: v.array(v.string()), + updatedAt: v.number(), + }).index("by_strategyId", ["strategyId"]), operationEvents: defineTable({ strategyId: v.id("strategies"), pageId: v.optional(v.id("pages")), diff --git a/convex/strategies.ts b/convex/strategies.ts index f1299e6d..6bb06970 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -1,5 +1,6 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { deleteStrategyAgentSummary } from "./lib/strategyAgentSummary"; import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { @@ -671,6 +672,7 @@ const deleteStrategy = mutation({ await ctx.scheduler.runAfter(0, markDeletedStrategyImageAssetsRef, { strategyId: strategy._id, }); + await deleteStrategyAgentSummary(ctx, strategy._id); await ctx.db.delete(strategy._id); return { ok: true } as const; }, diff --git a/lib/collab/cloud_library_models.dart b/lib/collab/cloud_library_models.dart index 57dbe995..1b329db3 100644 --- a/lib/collab/cloud_library_models.dart +++ b/lib/collab/cloud_library_models.dart @@ -1,7 +1,18 @@ +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/domain/folder.dart'; import 'package:icarus/strategy/strategy_models.dart'; -typedef CloudFolderEntry = ({Folder folder, String role}); +/// A cloud folder plus what the server says it holds across its subtree: +/// strategy count, the two most-used maps, and the agents in play. The +/// client never loads a folder's strategies to draw its card. +typedef CloudFolderEntry = ({ + Folder folder, + String role, + int strategyCount, + List mapPeeks, + List agentTypes, +}); typedef CloudStrategyEntry = ({ StrategyData strategy, diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index d0da913a..85eb46ac 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -6,6 +6,7 @@ import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; import 'package:icarus/collab/transport/convex_transport_adapter.dart'; +import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/domain/folder.dart'; @@ -515,9 +516,34 @@ CloudFolderEntry _folderEntry(FoldersListTreeResultItem folder) { customColor: folderCustomColorFromCloud(folder.customColorValue?.toInt()), ), role: folder.role.wireName, + strategyCount: folder.strategyCount.toInt(), + mapPeeks: [ + for (final wireName in folder.mapPeeks) + if (_mapValueOrNull(wireName) case final map?) map, + ], + agentTypes: [ + for (final name in folder.agentTypes) + if (_agentTypeOrNull(name) case final type?) type, + ], ); } +/// Unknown wire names are skipped: a peek at a map or agent this build does +/// not know yet must not break the folder list. +MapValue? _mapValueOrNull(String wireName) { + for (final entry in Maps.mapNames.entries) { + if (entry.value == wireName) return entry.key; + } + return null; +} + +AgentType? _agentTypeOrNull(String name) { + for (final type in AgentType.values) { + if (type.name == name) return type; + } + return null; +} + CloudStrategyEntry _strategyEntry( StrategiesListForFolderResultItem strategy, ) { diff --git a/lib/collab/generated/convex_models.dart b/lib/collab/generated/convex_models.dart index eec835b1..1baac964 100644 --- a/lib/collab/generated/convex_models.dart +++ b/lib/collab/generated/convex_models.dart @@ -649,6 +649,7 @@ final class FoldersDeleteResult { final class FoldersListTreeResultItem { const FoldersListTreeResultItem({ + required this.agentTypes, required this.color, required this.createdAt, required this.customColorValue, @@ -656,12 +657,15 @@ final class FoldersListTreeResultItem { required this.iconFontFamily, required this.iconFontPackage, required this.iconId, + required this.mapPeeks, required this.name, required this.parentFolderPublicId, required this.publicId, required this.role, + required this.strategyCount, required this.updatedAt, }); + final List agentTypes; final String? color; final double createdAt; final double? customColorValue; @@ -669,15 +673,18 @@ final class FoldersListTreeResultItem { final String? iconFontFamily; final String? iconFontPackage; final double? iconId; + final List mapPeeks; final String name; final String? parentFolderPublicId; final String publicId; final FoldersListTreeResultItemRole role; + final double strategyCount; final double updatedAt; factory FoldersListTreeResultItem.decode(ConvexValue value, String path) { final object = _decodeObject(value, path); _checkObjectFields(object, path, const { + 'agentTypes', 'color', 'createdAt', 'customColorValue', @@ -685,13 +692,27 @@ final class FoldersListTreeResultItem { 'iconFontFamily', 'iconFontPackage', 'iconId', + 'mapPeeks', 'name', 'parentFolderPublicId', 'publicId', 'role', + 'strategyCount', 'updatedAt', }); return FoldersListTreeResultItem( + agentTypes: + _decodeArray( + object.value['agentTypes'] ?? _missing(path, 'agentTypes'), + '$path.agentTypes', + ).value.indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('$path.agentTypes', entry.$1), + ), + ) + .toList(growable: false), color: (object.value['color'] ?? _missing(path, 'color')) is ConvexNull ? null : _decodeString( @@ -744,6 +765,18 @@ final class FoldersListTreeResultItem { object.value['iconId'] ?? _missing(path, 'iconId'), '$path.iconId', ), + mapPeeks: + _decodeArray( + object.value['mapPeeks'] ?? _missing(path, 'mapPeeks'), + '$path.mapPeeks', + ).value.indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('$path.mapPeeks', entry.$1), + ), + ) + .toList(growable: false), name: _decodeString( object.value['name'] ?? _missing(path, 'name'), '$path.name', @@ -769,6 +802,10 @@ final class FoldersListTreeResultItem { ), '$path.role', ), + strategyCount: _decodeNumber( + object.value['strategyCount'] ?? _missing(path, 'strategyCount'), + '$path.strategyCount', + ), updatedAt: _decodeNumber( object.value['updatedAt'] ?? _missing(path, 'updatedAt'), '$path.updatedAt', @@ -778,6 +815,11 @@ final class FoldersListTreeResultItem { ConvexObject encode(String path) { return ConvexObject({ + 'agentTypes': ConvexArray( + agentTypes.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), 'color': color == null ? const ConvexNull() : ConvexString(color!), 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), 'customColorValue': customColorValue == null @@ -795,12 +837,18 @@ final class FoldersListTreeResultItem { 'iconId': iconId == null ? const ConvexNull() : _encodeNumber(iconId!, '$path.iconId'), + 'mapPeeks': ConvexArray( + mapPeeks.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), 'name': ConvexString(name), 'parentFolderPublicId': parentFolderPublicId == null ? const ConvexNull() : ConvexString(parentFolderPublicId!), 'publicId': ConvexString(publicId), 'role': ConvexString(role.wireName), + 'strategyCount': _encodeNumber(strategyCount, '$path.strategyCount'), 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), }); } diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 9bb5f754..f6add7ac 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/color_option.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; @@ -236,6 +237,79 @@ class Settings { static const Color settingsDiscordAccent = Color(0xff5865f2); // brand blurple static const Color settingsMapAccent = Color(0xffb27c40); // map layers + // Resting glyph color for toolbar controls: a step under foreground so the + // strip of icons stays quiet, but above mutedForeground, which vanishes at + // the light stroke weights. Hover still comes up to foreground. + static const Color toolbarGlyph = Color(0xffd4d4d8); // zinc-300 + + // Raised surfaces (a selected tab, a checked tool, a primary command) are + // lit from above: the fill runs lighter at the top, a bright 1px edge sits + // inside the top, a dark 1px edge inside the bottom, and a 1px shadow drops + // beneath. The sides stay bare. Everything paints inside or 1px under the + // box, so the footprint never changes. Hover stays flat. + static const Color raisedTopLight = Color(0x24ffffff); // white 14% + static const Color raisedBottomShade = Color(0x4d000000); // black 30% + static const List raisedRim = [ + InsetShadow(color: raisedTopLight, offset: Offset(0, 1)), + InsetShadow(color: raisedBottomShade, offset: Offset(0, -1)), + ]; + static const BoxShadow raisedDropShadow = BoxShadow( + color: Color(0x73000000), // black 45% + offset: Offset(0, 1), + ); + // How far the fill's top and bottom move from the base color, in HSL + // lightness. These two numbers set the lift for every raised surface. + static const double raisedTopLift = 0.06; + static const double raisedBottomDrop = 0.03; + + /// The lit fill for any base color: lighter at the top, darker at the + /// bottom, so one recipe serves violet, zinc, red, and the rest. + static LinearGradient raisedGradient(Color base) { + final hsl = HSLColor.fromColor(base); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + hsl + .withLightness((hsl.lightness + raisedTopLift).clamp(0, 1)) + .toColor(), + hsl + .withLightness((hsl.lightness - raisedBottomDrop).clamp(0, 1)) + .toColor(), + ], + ); + } + + /// A raised surface of [base] color at [radius]. Use this wherever a + /// selected or primary state would otherwise be a flat fill. + static InsetShadowDecoration raised(Color base, double radius) => + InsetShadowDecoration( + gradient: raisedGradient(base), + borderRadius: BorderRadius.circular(radius), + boxShadows: const [raisedDropShadow], + shadows: raisedRim, + ); + + /// The raised neutral surface: a selected tab or chip. + static InsetShadowDecoration raisedSurface(double radius) => + raised(tacticalVioletTheme.secondary, radius); + + /// The raised command surface: a checked tool, the active segment, the + /// active page, anything that would otherwise be a flat `primary` fill. + static InsetShadowDecoration raisedPrimary(double radius) => + raised(tacticalVioletTheme.primary, radius); + + /// The primary fill alone, for the Shad theme and animated fills. + static final LinearGradient raisedPrimaryFill = + raisedGradient(tacticalVioletTheme.primary); + + // The shadow a floating menu earns (DESIGN.md: 0 8px 24px rgba(0,0,0,0.28)). + static const BoxShadow floatingMenuShadow = BoxShadow( + color: Color(0x47000000), + blurRadius: 24, + offset: Offset(0, 8), + ); + static const cardForegroundBackdrop = BoxShadow( color: Colors.black54, // High opacity because the background is dark blurRadius: 12, @@ -260,13 +334,7 @@ class Settings { return Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), + decoration: Settings.raised(backgroundColor, 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/main.dart b/lib/main.dart index 11ec3f71..28cef0e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -46,7 +46,7 @@ import 'package:icarus/services/discord_presence_service.dart'; import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; -import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/startup/hive_store_launch.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/global_shortcuts.dart'; @@ -128,22 +128,43 @@ Future main(List args) async { isReleaseMode: kReleaseMode, ); + final launch = HiveStoreLaunch.parse(args); + final PreparedHiveStore? alternateHiveStore; + if (kIsWeb) { + launch.validateForWeb(); + alternateHiveStore = null; + } else { + alternateHiveStore = await launch.prepareAlternateStore( + getDefaultHiveDirectory: getApplicationSupportDirectory, + ); + } + await registerDeepLinkProtocol('icarus'); await _initializeDeepLinkHandling(); if (kIsWeb && isIcarusShareUri(Uri.base)) { _publishDeepLink(Uri.base, source: 'web_location'); } - await ensureIcarusSingleInstance(args); + await ensureIcarusSingleInstance( + launch.fileOpenArgs, + instanceId: alternateHiveStore?.windowsSingleInstanceId ?? + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); if (kIsWeb) { // On web, Hive uses IndexedDB; no path needed. await Hive.initFlutter(); } else { - // On mobile/desktop, you can still choose an explicit directory. - final dir = await getApplicationSupportDirectory(); + final hiveDirectoryPath = alternateHiveStore?.hiveDirectoryPath ?? + (await getApplicationSupportDirectory()).path; await getTemporaryDirectory(); - await Hive.initFlutter(dir.path); + await Hive.initFlutter(hiveDirectoryPath); + if (alternateHiveStore != null) { + AppErrorReporter.reportInfo( + 'Using alternate Hive store: $hiveDirectoryPath', + source: 'main.hiveStore', + ); + } } staticDrawingCursor = await CustomMouseCursor.icon( @@ -200,7 +221,7 @@ Future main(List args) async { runApp( UncontrolledProviderScope( container: appProviderContainer, - child: MyApp(data: args), + child: MyApp(data: launch.fileOpenArgs), ), ); }, @@ -539,6 +560,25 @@ class _MyAppState extends ConsumerState { brightness: Brightness.dark, colorScheme: Settings.tacticalVioletTheme, breadcrumbTheme: const ShadBreadcrumbTheme(separatorSize: 18), + // Ghost buttons are quiet controls (menu items, icon buttons), + // not primary commands, so they don't get the command color. + ghostButtonTheme: ShadButtonTheme( + foregroundColor: Settings.tacticalVioletTheme.foreground, + ), + // Primary commands are raised like the selected tab: a lighter top + // of the fill, a bright 1px edge inside the top, a 1px shadow + // beneath. A rounded Border must be one color, so the theme paints + // the top light only and the gradient carries the bottom shade. + primaryButtonTheme: ShadButtonTheme( + decoration: ShadDecoration( + gradient: Settings.raisedPrimaryFill, + shadows: const [Settings.raisedDropShadow], + border: const ShadBorder( + radius: BorderRadius.all(Radius.circular(6)), + top: ShadBorderSide(color: Settings.raisedTopLight, width: 1), + ), + ), + ), ), home: const MyHomePage(), routes: { diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 492329bc..0aa33029 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -13,7 +13,6 @@ import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/cloud_library_action.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; diff --git a/lib/providers/strategy_filter_provider.dart b/lib/providers/strategy_filter_provider.dart index df04479c..d0fb23a5 100644 --- a/lib/providers/strategy_filter_provider.dart +++ b/lib/providers/strategy_filter_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -89,11 +90,11 @@ class StrategyFilterProvider extends Notifier { switch (state.sortBy) { case SortBy.alphabetical: - return (icon: Icons.sort_by_alpha, label: label); + return (icon: LucideIcons.arrowDownAZ, label: label); case SortBy.dateCreated: - return (icon: Icons.calendar_today, label: label); + return (icon: LucideIcons.calendarPlus, label: label); case SortBy.dateUpdated: - return (icon: Icons.update, label: label); + return (icon: LucideIcons.history, label: label); } } } diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 7cca0823..cb20e04f 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -33,7 +33,6 @@ import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/providers/view_cone_geometry_provider.dart'; import 'package:icarus/strategy/strategy_page_apply.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy/strategy_page_source.dart'; import 'package:icarus/view_cone/vision_geometry.dart'; diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index f40f5052..9c2ef4f9 100644 --- a/lib/screenshot/screenshot_view.dart +++ b/lib/screenshot/screenshot_view.dart @@ -15,7 +15,6 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; diff --git a/lib/services/desktop_runtime_native.dart b/lib/services/desktop_runtime_native.dart index 841fb5ab..92f80c55 100644 --- a/lib/services/desktop_runtime_native.dart +++ b/lib/services/desktop_runtime_native.dart @@ -2,20 +2,25 @@ import 'dart:io'; import 'package:flutter/material.dart' show Size; import 'package:icarus/const/second_instance_args.dart'; +import 'package:icarus/startup/windows_process_termination.dart'; import 'package:window_manager/window_manager.dart'; import 'package:windows_single_instance/windows_single_instance.dart'; bool get isWindowsRuntime => Platform.isWindows; -Future ensureIcarusSingleInstance(List args) async { +Future ensureIcarusSingleInstance( + List args, { + required String instanceId, +}) async { if (!Platform.isWindows) { return; } await WindowsSingleInstance.ensureSingleInstance( args, - 'icarus_single_instance', + instanceId, onSecondWindow: publishSecondInstanceArgs, + exitFunction: terminateDuplicateWindowsProcess, ); } diff --git a/lib/services/desktop_runtime_stub.dart b/lib/services/desktop_runtime_stub.dart index 09c4e3e8..ce339cac 100644 --- a/lib/services/desktop_runtime_stub.dart +++ b/lib/services/desktop_runtime_stub.dart @@ -1,5 +1,8 @@ bool get isWindowsRuntime => false; -Future ensureIcarusSingleInstance(List args) async {} +Future ensureIcarusSingleInstance( + List args, { + required String instanceId, +}) async {} Future initializeIcarusDesktopWindow(String title) async {} diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index ea9c7ee6..5b069474 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -33,14 +33,11 @@ Future showUnsavedStrategyDialog( builder: (context) { return ShadDialog.alert( title: const Text('Save changes?'), - description: const Padding( - padding: EdgeInsets.all(8), - child: Text( - 'This strategy has unsaved changes. Do you want to save before leaving?', - ), + description: const Text( + 'This strategy has unsaved changes. Do you want to save before leaving?', ), actions: [ - ShadButton.secondary( + ShadButton.ghost( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.cancel); }, @@ -50,7 +47,7 @@ Future showUnsavedStrategyDialog( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.dontSave); }, - child: const Text("Don't Save"), + child: const Text("Don't save"), ), ShadButton( onPressed: () { @@ -77,37 +74,44 @@ Future _showCloudSyncBlockedDialog( builder: (context) { return ShadDialog.alert( title: const Text('Cloud sync pending'), - actionsAxis: Axis.vertical, - description: Padding( - padding: const EdgeInsets.all(8), - child: Text(message), - ), + description: Text(message), + // One row that wraps when the labels need more room: the quiet way + // out first, the command last. actions: [ - ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.stay); - }, - child: const Text('Stay Here'), - ), - if (showRetryAuth) - ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.retryAuth); - }, - child: const Text('Retry Convex Auth'), + Expanded( + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.ghost( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.stay); + }, + child: const Text('Stay here'), + ), + if (showRetryAuth) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retryAuth); + }, + child: const Text('Retry sign-in'), + ), + if (allowLeaveAnyway) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.leaveAnyway); + }, + child: const Text('Leave anyway'), + ), + ShadButton( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retrySync); + }, + child: const Text('Retry sync'), + ), + ], ), - if (allowLeaveAnyway) - ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.leaveAnyway); - }, - child: const Text('Leave Anyway'), - ), - ShadButton( - onPressed: () { - Navigator.of(context).pop(CloudExitDecision.retrySync); - }, - child: const Text('Retry Sync'), ), ], ); diff --git a/lib/sidebar.dart b/lib/sidebar.dart index f0b0e753..d20ff37a 100644 --- a/lib/sidebar.dart +++ b/lib/sidebar.dart @@ -43,7 +43,11 @@ class _SideBarUIState extends ConsumerState { const AbiilityBar(), Padding( padding: const EdgeInsets.only( - left: 0, right: Settings.sideBarPanelPaddingRight, bottom: 8), + left: 0, + top: 8, + right: Settings.sideBarPanelPaddingRight, + bottom: 8, + ), child: ClipRRect( borderRadius: _panelBorderRadius, child: Container( @@ -103,7 +107,7 @@ class _SideBarUIState extends ConsumerState { .toggleFavoritesOnly(); }, icon: Icon( - Icons.star_rounded, + LucideIcons.star600, size: 24, color: filterState.favoritesOnly ? Colors.white diff --git a/lib/startup/hive_store_launch.dart b/lib/startup/hive_store_launch.dart new file mode 100644 index 00000000..81b55c0e --- /dev/null +++ b/lib/startup/hive_store_launch.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cryptography_plus/cryptography_plus.dart'; +import 'package:path/path.dart' as path; + +final class HiveStoreLaunchException implements Exception { + const HiveStoreLaunchException(this.message, [this.cause]); + + final String message; + final Object? cause; + + @override + String toString() => message; +} + +final class HiveStoreLaunch { + const HiveStoreLaunch._({ + required this.fileOpenArgs, + required String? alternateHiveDirectory, + }) : _alternateHiveDirectory = alternateHiveDirectory; + + static const optionName = '--hive-store-dir'; + static const defaultWindowsSingleInstanceId = 'icarus_single_instance'; + + final List fileOpenArgs; + final String? _alternateHiveDirectory; + + static HiveStoreLaunch parse(List rawArgs) { + final fileOpenArgs = []; + String? alternateHiveDirectory; + var sawOption = false; + + for (var index = 0; index < rawArgs.length; index += 1) { + final argument = rawArgs[index]; + if (argument == optionName) { + if (sawOption) { + throw const HiveStoreLaunchException( + '$optionName may only be supplied once.', + ); + } + if (index + 1 >= rawArgs.length || + rawArgs[index + 1] == optionName || + rawArgs[index + 1].startsWith('$optionName=')) { + throw const HiveStoreLaunchException( + '$optionName requires an absolute directory path.', + ); + } + sawOption = true; + alternateHiveDirectory = rawArgs[++index]; + _validateOptionValue(alternateHiveDirectory); + continue; + } + + if (argument.startsWith('$optionName=')) { + if (sawOption) { + throw const HiveStoreLaunchException( + '$optionName may only be supplied once.', + ); + } + sawOption = true; + alternateHiveDirectory = argument.substring(optionName.length + 1); + _validateOptionValue(alternateHiveDirectory); + continue; + } + + fileOpenArgs.add(argument); + } + + return HiveStoreLaunch._( + fileOpenArgs: List.unmodifiable(fileOpenArgs), + alternateHiveDirectory: alternateHiveDirectory, + ); + } + + void validateForWeb() { + if (_alternateHiveDirectory == null) return; + throw const HiveStoreLaunchException( + '$optionName is only available in desktop builds.', + ); + } + + Future prepareAlternateStore({ + required Future Function() getDefaultHiveDirectory, + Future Function(Directory directory)? probeDirectory, + }) async { + final requestedPath = _alternateHiveDirectory; + if (requestedPath == null) return null; + if (!path.isAbsolute(requestedPath)) { + throw HiveStoreLaunchException( + '$optionName requires an absolute path: $requestedPath', + ); + } + + try { + final requestedDirectory = Directory(path.normalize(requestedPath)); + await requestedDirectory.create(recursive: true); + final canonicalPath = path.normalize( + await requestedDirectory.resolveSymbolicLinks(), + ); + final canonicalDirectory = Directory(canonicalPath); + await (probeDirectory ?? _probeDirectory)(canonicalDirectory); + + final defaultDirectory = await getDefaultHiveDirectory(); + final defaultPath = await _resolvedDirectoryPath(defaultDirectory); + final isDefaultDirectory = + _pathIdentity(canonicalPath) == _pathIdentity(defaultPath); + + return PreparedHiveStore( + hiveDirectoryPath: canonicalPath, + windowsSingleInstanceId: isDefaultDirectory + ? defaultWindowsSingleInstanceId + : await _windowsSingleInstanceId(canonicalPath), + ); + } on HiveStoreLaunchException { + rethrow; + } catch (error) { + throw HiveStoreLaunchException( + 'Could not prepare Hive store directory: $requestedPath', + error, + ); + } + } + + static void _validateOptionValue(String value) { + if (value.isEmpty) { + throw const HiveStoreLaunchException( + '$optionName requires a non-empty directory path.', + ); + } + if (value.contains('\u0000')) { + throw const HiveStoreLaunchException( + '$optionName cannot contain a NUL character.', + ); + } + } + + static Future _probeDirectory(Directory directory) async { + final probeDirectory = await directory.createTemp('.icarus-hive-probe-'); + try { + final probeFile = File(path.join(probeDirectory.path, 'write-probe')); + await probeFile.writeAsBytes(const [0], flush: true); + } finally { + if (await probeDirectory.exists()) { + await probeDirectory.delete(recursive: true); + } + } + } + + static Future _resolvedDirectoryPath(Directory directory) async { + final absoluteDirectory = + Directory(path.normalize(directory.absolute.path)); + if (!await absoluteDirectory.exists()) return absoluteDirectory.path; + return path.normalize(await absoluteDirectory.resolveSymbolicLinks()); + } + + static String _pathIdentity(String directoryPath) { + final normalized = path.normalize(directoryPath); + return Platform.isWindows ? normalized.toLowerCase() : normalized; + } + + static Future _windowsSingleInstanceId( + String canonicalPath, + ) async { + final digest = + await Sha256().hash(utf8.encode(_pathIdentity(canonicalPath))); + final hex = digest.bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${defaultWindowsSingleInstanceId}_$hex'; + } +} + +final class PreparedHiveStore { + const PreparedHiveStore({ + required this.hiveDirectoryPath, + required this.windowsSingleInstanceId, + }); + + final String hiveDirectoryPath; + final String windowsSingleInstanceId; +} diff --git a/lib/startup/windows_process_termination.dart b/lib/startup/windows_process_termination.dart new file mode 100644 index 00000000..22b6e999 --- /dev/null +++ b/lib/startup/windows_process_termination.dart @@ -0,0 +1,2 @@ +export 'windows_process_termination_stub.dart' + if (dart.library.io) 'windows_process_termination_io.dart'; diff --git a/lib/startup/windows_process_termination_io.dart b/lib/startup/windows_process_termination_io.dart new file mode 100644 index 00000000..53c2f120 --- /dev/null +++ b/lib/startup/windows_process_termination_io.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:win32/win32.dart'; + +Future terminateDuplicateWindowsProcess() { + if (!Platform.isWindows) { + throw UnsupportedError('Windows process termination requires Windows.'); + } + + // flutter_inappwebview_windows can crash in DLL_PROCESS_DETACH while + // releasing static WinRT state. This runs after argument forwarding and + // before Hive opens, so skipping DLL cleanup cannot interrupt library writes. + // See https://github.com/pichillilorenzo/flutter_inappwebview/issues/2733. + final result = TerminateProcess(GetCurrentProcess(), 0); + if (result == 0) { + throw WindowsException(HRESULT_FROM_WIN32(GetLastError())); + } + + // Keep startup suspended until Windows finishes terminating the process. + return Completer().future; +} diff --git a/lib/startup/windows_process_termination_stub.dart b/lib/startup/windows_process_termination_stub.dart new file mode 100644 index 00000000..ea3a436b --- /dev/null +++ b/lib/startup/windows_process_termination_stub.dart @@ -0,0 +1,3 @@ +Future terminateDuplicateWindowsProcess() async { + throw UnsupportedError('Windows process termination requires Windows.'); +} diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 8b29f31e..a6b3128f 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -36,7 +36,6 @@ import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/archive_manifest.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 46f33a4c..127d8223 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -23,7 +23,7 @@ import 'package:icarus/widgets/strategy_edit_boundary.dart'; import 'package:icarus/widgets/strategy_quick_switcher.dart'; import 'package:icarus/widgets/map_selector.dart'; import 'package:icarus/widgets/pages_bar.dart'; -import 'package:icarus/widgets/save_and_load_button.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -218,74 +218,46 @@ class _StrategyViewState extends ConsumerState return Scaffold( body: Column( children: [ - EditorWindowHeader( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: LayoutBuilder( - builder: (context, constraints) { - final showDiscordLabel = constraints.maxWidth >= 1000; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(Icons.home), - ), - const SizedBox(width: 5), - const StrategyEditBoundary( - disabledOpacity: 0.55, - child: MapSelector(), - ), - if (kIsWeb) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: DemoTag(), - ), - ], + // The same 40px strip as the library, so the traffic lights never + // move: Library on the left, the strategy in the middle, Discord on + // the right. The map card lives on the canvas with the toolbar. + AppWindowStrip( + child: Stack( + children: [ + Row( + children: [ + const SizedBox(width: 6), + ShadTooltip( + builder: (context) => const Text('Library'), + child: ShadIconButton.ghost( + width: 28, + height: 28, + foregroundColor: + Settings.tacticalVioletTheme.mutedForeground, + hoverForegroundColor: + Settings.tacticalVioletTheme.foreground, + onPressed: _leaveToLibrary, + icon: const Icon(LucideIcons.house300, size: 18), ), - const StrategyQuickSwitcher(), - if (showDiscordLabel) - TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.white, - enabledMouseCursor: SystemMouseCursors.click, - ), - onPressed: () async { - await launchUrl(Settings.dicordLink); - }, - child: const Row( - children: [ - Text("Have any bugs? Join the Discord"), - SizedBox(width: 10), - Icon( - CustomIcons.discord, - color: Colors.white, - ), - ], - ), - ) - else - Tooltip( - message: 'Have any bugs? Join the Discord', - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - await launchUrl(Settings.dicordLink); - }, - icon: const Icon(CustomIcons.discord), - ), - ), - ], - ); - }, - ), + ), + if (kIsWeb) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: DemoTag(), + ), + const Expanded( + child: WindowDragArea(child: SizedBox.expand()), + ), + const _DiscordLink(), + const SizedBox(width: 10), + ], + ), + const Center(child: StrategyQuickSwitcher()), + ], ), ), + // The canvas runs right up to the strip; each floating panel keeps + // its own 8px of air so no bare band shows between the two. const Expanded( child: Stack( clipBehavior: Clip.none, @@ -297,7 +269,24 @@ class _StrategyViewState extends ConsumerState alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), ), - Align(alignment: Alignment.topLeft, child: SaveAndLoadButton()), + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), + SizedBox(height: 8), + EditorToolbar(), + ], + ), + ), + ), Align( alignment: Alignment.bottomLeft, child: Padding( @@ -342,3 +331,43 @@ class _StrategyViewState extends ConsumerState ); } } + +/// The bug-report link at the end of the editor strip: text when there is +/// room, the glyph alone when there isn't. +class _DiscordLink extends StatelessWidget { + const _DiscordLink(); + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final showLabel = MediaQuery.sizeOf(context).width >= 1000; + final button = ShadButton.ghost( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: () async { + await launchUrl(Settings.dicordLink); + }, + leading: showLabel ? null : const Icon(CustomIcons.discord, size: 16), + child: showLabel + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Have any bugs? Join the Discord', + style: TextStyle(fontSize: 13), + ), + SizedBox(width: 8), + Icon(CustomIcons.discord, size: 16), + ], + ) + : const SizedBox.shrink(), + ); + if (showLabel) return button; + return ShadTooltip( + builder: (context) => const Text('Have any bugs? Join the Discord'), + child: button, + ); + } +} diff --git a/lib/widgets/better_color_picker.dart b/lib/widgets/better_color_picker.dart index 538eca35..611e6e85 100644 --- a/lib/widgets/better_color_picker.dart +++ b/lib/widgets/better_color_picker.dart @@ -1004,7 +1004,7 @@ class _ModeField extends material.StatelessWidget { ); }, trailing: material.Icon( - material.Icons.unfold_more, + LucideIcons.chevronsUpDown, size: 16, color: palette.mutedForeground, ), diff --git a/lib/widgets/cloud_outbox_summary_banner.dart b/lib/widgets/cloud_outbox_summary_banner.dart index 96b0b514..c7431b55 100644 --- a/lib/widgets/cloud_outbox_summary_banner.dart +++ b/lib/widgets/cloud_outbox_summary_banner.dart @@ -78,44 +78,56 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { 'waiting on this device and will resume when the ' 'connection returns.'; final theme = ShadTheme.of(context); + // A floating status card, not a banner: it sits over the library's + // corner like a floating menu and never moves the grid. The parent + // positions it. return Container( key: const ValueKey('cloud-outbox-summary'), - margin: const EdgeInsets.fromLTRB(24, 16, 24, 0), + constraints: const BoxConstraints(maxWidth: 360), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, + color: Settings.tacticalVioletTheme.popover, border: Border.all(color: Settings.tacticalVioletTheme.border), borderRadius: BorderRadius.circular(12), + boxShadow: const [Settings.floatingMenuShadow], ), child: Row( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - needsAttention - ? LucideIcons.circleAlert - : connected - ? LucideIcons.cloudUpload - : LucideIcons.cloudOff, - size: 18, - color: needsAttention - ? theme.colorScheme.destructive - : theme.colorScheme.mutedForeground, + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + needsAttention + ? LucideIcons.circleAlert + : connected + ? LucideIcons.cloudUpload + : LucideIcons.cloudOff, + size: 16, + color: needsAttention + ? theme.colorScheme.destructive + : theme.colorScheme.mutedForeground, + ), ), const SizedBox(width: 10), - Expanded( + Flexible( child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( detail, - style: theme.textTheme.small.copyWith( + style: TextStyle( + fontSize: 12, + height: 1.35, color: theme.colorScheme.mutedForeground, ), ), @@ -126,16 +138,22 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { runSpacing: 8, children: [ for (final strategyId in attentionIds) - ShadButton.outline( + ShadButton.secondary( size: ShadButtonSize.sm, onPressed: () => _openStrategy(context, strategyId), - child: Text(_attentionLabel( - strategyId, - strategyNames[strategyId], - opQueue - .accountOutbox.strategies[strategyId]?.reason, - failedMediaByStrategy[strategyId] ?? 0, - )), + child: Flexible( + child: Text( + _attentionLabel( + strategyId, + strategyNames[strategyId], + opQueue.accountOutbox.strategies[strategyId] + ?.reason, + failedMediaByStrategy[strategyId] ?? 0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), ], ), diff --git a/lib/widgets/cloud_sync_status_chip.dart b/lib/widgets/cloud_sync_button.dart similarity index 70% rename from lib/widgets/cloud_sync_status_chip.dart rename to lib/widgets/cloud_sync_button.dart index 27a25b09..6a923094 100644 --- a/lib/widgets/cloud_sync_status_chip.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -13,28 +13,34 @@ import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -const _chipSwitchDuration = Duration(milliseconds: 150); +const _glyphSwitchDuration = Duration(milliseconds: 150); const _conflictToastGap = Duration(seconds: 5); enum _SyncStatus { synced, editing, syncing, offline, attention } -/// Persistent cloud sync indicator for the strategy editor top strip. +/// The save button of a cloud strategy. Its glyph is the sync state, so the +/// promise that work is on the server has a face without a text chip: +/// synced, editing, syncing, offline, or needs attention. /// -/// Renders nothing for local strategies. For cloud strategies it shows one of -/// synced / syncing / offline / needs-attention, with a popover explaining the -/// state and offering recovery when something failed. Also surfaces conflicts -/// (the server rejected an edit while retaining the local intent) as a toast. -class CloudSyncStatusChip extends ConsumerStatefulWidget { - const CloudSyncStatusChip({super.key}); +/// Pressing it saves now. When sync needs attention the press opens a popover +/// that explains what happened and offers recovery instead. Conflicts (the +/// server rejected an edit while the local intent was kept) surface as a toast. +/// +/// Renders nothing for local strategies; [AutoSaveButton] covers those. +class CloudSyncButton extends ConsumerStatefulWidget { + const CloudSyncButton({super.key, required this.style}); + + final EditorToolbarButtonStyle style; @override - ConsumerState createState() => - _CloudSyncStatusChipState(); + ConsumerState createState() => _CloudSyncButtonState(); } -class _CloudSyncStatusChipState extends ConsumerState { +class _CloudSyncButtonState extends ConsumerState { final ShadPopoverController _popoverController = ShadPopoverController(); DateTime? _lastConflictToast; Timer? _pendingConflictToast; @@ -83,6 +89,14 @@ class _CloudSyncStatusChipState extends ConsumerState { ref.read(strategyConflictProvider.notifier).clearAll(); } + Future _handlePressed(_SyncStatus status) async { + if (status == _SyncStatus.attention) { + _popoverController.toggle(); + return; + } + await saveStrategyNow(context, ref); + } + Future _retry() async { if (_isResolving) return; setState(() { @@ -183,13 +197,18 @@ class _CloudSyncStatusChipState extends ConsumerState { CloudSyncStatus.attention => _SyncStatus.attention, }; + final tooltip = _tooltip(status, saveState.lastPersistedAt); + final foreground = status == _SyncStatus.attention + ? Settings.tacticalVioletTheme.destructive + : null; + return ShadPopover( controller: _popoverController, padding: const EdgeInsets.all(14), anchor: const ShadAnchor( offset: Offset(0, 8), - childAlignment: Alignment.topCenter, - overlayAlignment: Alignment.bottomCenter, + childAlignment: Alignment.topLeft, + overlayAlignment: Alignment.bottomLeft, ), popover: (context) => _SyncStatusPopover( status: status, @@ -203,136 +222,86 @@ class _CloudSyncStatusChipState extends ConsumerState { onRetry: _retry, onUseCloudVersions: _useCloudVersions, ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _popoverController.toggle, - child: AnimatedContainer( - duration: _chipSwitchDuration, - curve: Curves.easeOutCubic, - height: 24, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: _chipBackground(status), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSwitcher( - duration: _chipSwitchDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: _chipIcon(status), - ), - const SizedBox(width: 6), - AnimatedSwitcher( - duration: _chipSwitchDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: Text( - _chipLabel(status), - key: ValueKey(status), - style: TextStyle( - color: _chipForeground(status), - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ), - ], - ), - ), - ), + child: EditorToolbarButton( + key: ValueKey('cloud-sync-button-${status.name}'), + style: widget.style, + tooltip: tooltip, + semanticsLabel: tooltip, + foregroundColor: foreground, + onPressed: () => _handlePressed(status), + icon: AnimatedSwitcher( + duration: _glyphSwitchDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: _glyph(status, foreground), ), ), ); } - Color _chipBackground(_SyncStatus status) { - switch (status) { - case _SyncStatus.attention: - return Settings.tacticalVioletTheme.destructive.withValues(alpha: 0.14); - case _SyncStatus.offline: - case _SyncStatus.editing: - case _SyncStatus.syncing: - case _SyncStatus.synced: - return Settings.tacticalVioletTheme.muted.withValues(alpha: 0.4); - } - } - - Color _chipForeground(_SyncStatus status) { - switch (status) { - case _SyncStatus.attention: - return Settings.tacticalVioletTheme.destructive; - case _SyncStatus.offline: - case _SyncStatus.editing: - case _SyncStatus.syncing: - case _SyncStatus.synced: - return Settings.tacticalVioletTheme.mutedForeground; - } - } - - Widget _chipIcon(_SyncStatus status) { - final color = _chipForeground(status); + Widget _glyph(_SyncStatus status, Color? color) { + // Lucide's cloud sits low in its box and reads smaller than the upload + // and camera glyphs beside it, so it gets 2px more. + final size = widget.style.iconSize + 2; switch (status) { case _SyncStatus.synced: return Icon( - Icons.cloud_done_outlined, + LucideIcons.cloudCheck200, key: const ValueKey('synced'), - size: 13, + size: size, color: color, ); case _SyncStatus.editing: return Icon( - Icons.edit_outlined, + LucideIcons.cloudUpload200, key: const ValueKey('editing'), - size: 13, + size: size, color: color, ); case _SyncStatus.syncing: return SizedBox( key: const ValueKey('syncing'), - width: 11, - height: 11, + width: size - 4, + height: size - 4, child: CircularProgressIndicator( - strokeWidth: 1.6, - valueColor: AlwaysStoppedAnimation(color), + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + color ?? Settings.tacticalVioletTheme.mutedForeground, + ), ), ); case _SyncStatus.offline: return Icon( - Icons.cloud_off_outlined, + LucideIcons.cloudOff200, key: const ValueKey('offline'), - size: 13, + size: size, color: color, ); case _SyncStatus.attention: return Icon( - Icons.error_outline, + LucideIcons.cloudAlert200, key: const ValueKey('attention'), - size: 13, + size: size, color: color, ); } } - String _chipLabel(_SyncStatus status) { + /// One line, what the glyph means and what a press will do. + static String _tooltip(_SyncStatus status, DateTime? lastSynced) { switch (status) { case _SyncStatus.synced: - return 'Synced'; + return lastSynced == null + ? 'Synced' + : 'Synced at ${_SyncStatusPopover._formatTime(lastSynced)}'; case _SyncStatus.editing: - return 'Editing…'; + return 'Edit not synced yet'; case _SyncStatus.syncing: return 'Syncing…'; case _SyncStatus.offline: - return 'Offline'; + return 'Offline, changes stay on this device'; case _SyncStatus.attention: - return 'Needs attention'; + return 'Sync needs attention'; } } } @@ -369,68 +338,78 @@ class _SyncStatusPopover extends StatelessWidget { final theme = ShadTheme.of(context); final lastSynced = saveState.lastPersistedAt; - return SizedBox( - width: 260, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _title, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - _explanation, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - height: 1.35, - ), - ), - if (resolutionError != null) ...[ - const SizedBox(height: 8), + // Text alignment is inherited from the editor, which centers, so pin it + // here; the column's own alignment does not reach inside the Texts. + return DefaultTextStyle.merge( + textAlign: TextAlign.start, + child: SizedBox( + width: 260, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - resolutionError!, + _title, style: theme.textTheme.small.copyWith( - color: theme.colorScheme.destructive, - height: 1.35, + color: theme.colorScheme.foreground, ), ), - ], - if (lastSynced != null) ...[ - const SizedBox(height: 8), + const SizedBox(height: 6), Text( - 'Last synced at ${_formatTime(lastSynced)}', + _explanation, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, - fontSize: 11, + height: 1.35, ), ), - ], - if (status == _SyncStatus.attention && - (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ - const SizedBox(height: 12), - if (hasRejectedWork) ...[ - ShadButton.secondary( - size: ShadButtonSize.sm, - expands: false, - onPressed: isResolving ? null : onUseCloudVersions, - child: const Text('Use cloud'), + if (resolutionError != null) ...[ + const SizedBox(height: 8), + Text( + resolutionError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + height: 1.35, + ), ), + ], + if (lastSynced != null) ...[ const SizedBox(height: 8), + Text( + 'Last synced at ${_formatTime(lastSynced)}', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 11, + ), + ), ], - ShadButton( - size: ShadButtonSize.sm, - expands: false, - onPressed: isResolving ? null : onRetry, - child: Text( - hasRejectedWork ? 'Keep mine' : 'Retry sync', + if (status == _SyncStatus.attention && + (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + if (hasRejectedWork) + ShadButton.secondary( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onUseCloudVersions, + child: const Text('Use cloud'), + ), + ShadButton( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onRetry, + child: Text( + hasRejectedWork ? 'Keep mine' : 'Retry sync', + ), + ), + ], ), - ), + ], ], - ], + ), ), ); } diff --git a/lib/widgets/color_picker_button.dart b/lib/widgets/color_picker_button.dart index 9743d2d4..38d695a1 100644 --- a/lib/widgets/color_picker_button.dart +++ b/lib/widgets/color_picker_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class ColorPickerButton extends ConsumerStatefulWidget { @@ -61,7 +62,7 @@ class _ColorButtonsState extends ConsumerState { strokeAlign: BorderSide.strokeAlignCenter, ), ), - child: const Icon(Icons.add), + child: const Icon(LucideIcons.plus, size: 18), ), ), ), diff --git a/lib/widgets/custom_expansion_tile.dart b/lib/widgets/custom_expansion_tile.dart index a9c5227f..b52e59d7 100644 --- a/lib/widgets/custom_expansion_tile.dart +++ b/lib/widgets/custom_expansion_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class CustomExpansionTile extends StatefulWidget { /// Creates a custom expansion tile that keeps a persistent row visible @@ -175,7 +176,8 @@ class _CustomExpansionTileState extends State RotationTransition( turns: _iconTurns, child: Icon( - Icons.expand_more, + LucideIcons.chevronDown, + size: 20, color: iconColor, ), ), diff --git a/lib/widgets/custom_search_field.dart b/lib/widgets/custom_search_field.dart index 12939e3c..37eeff9b 100644 --- a/lib/widgets/custom_search_field.dart +++ b/lib/widgets/custom_search_field.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; /// A themed search text field that smoothly expands (slides out) when: /// - Hovered by the pointer @@ -171,7 +172,7 @@ class _SearchTextFieldState extends ConsumerState { ? const EdgeInsets.only(left: 8, right: 8) : const EdgeInsets.only(left: 12, right: 8), child: Icon( - Icons.search, + LucideIcons.search, color: Colors.white, size: compact ? 18 : 20, ), @@ -184,7 +185,7 @@ class _SearchTextFieldState extends ConsumerState { ? IconButton( tooltip: 'Clear', icon: Icon( - Icons.close, + LucideIcons.x, size: compact ? 18 : 20, color: Colors.white70, ), diff --git a/lib/widgets/custom_segmented_tabs.dart b/lib/widgets/custom_segmented_tabs.dart index db0d7d67..ad506724 100644 --- a/lib/widgets/custom_segmented_tabs.dart +++ b/lib/widgets/custom_segmented_tabs.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; enum SegmentedIndicatorBehavior { slidingPill, @@ -207,10 +208,7 @@ class _CustomSegmentedTabsState extends State> { width: _segmentWidths[selectedIndex], bottom: 0, child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(_segmentRadius), - ), + decoration: Settings.raisedPrimary(_segmentRadius), ), ), Row( @@ -296,12 +294,11 @@ class _TabButton extends StatelessWidget { horizontal: horizontalPadding, vertical: verticalPadding, ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: showStaticFill && isSelected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent, - ), + decoration: showStaticFill && isSelected + ? Settings.raisedPrimary(borderRadius) + : InsetShadowDecoration( + borderRadius: BorderRadius.circular(borderRadius), + ), child: DefaultTextStyle.merge( style: TextStyle( color: textColor, diff --git a/lib/widgets/custom_text_field.dart b/lib/widgets/custom_text_field.dart index 669b8f25..b15da7f6 100644 --- a/lib/widgets/custom_text_field.dart +++ b/lib/widgets/custom_text_field.dart @@ -17,6 +17,7 @@ class CustomTextField extends StatefulWidget { this.obscureText = false, this.textInputAction, this.hasError = false, + this.autofocus = false, }); final TextEditingController? controller; final FocusNode? focusNode; @@ -32,6 +33,7 @@ class CustomTextField extends StatefulWidget { /// Draws a destructive border when true (e.g. failed validation). final bool hasError; + final bool autofocus; @override State createState() => _CustomTextFieldState(); @@ -77,6 +79,7 @@ class _CustomTextFieldState extends State { : null, controller: _controller, focusNode: _focusNode, + autofocus: widget.autofocus, textAlign: widget.textAlign ?? TextAlign.start, minLines: widget.minLines, maxLines: widget.maxLines ?? 1, diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart index fb0916c9..e121611a 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -225,7 +226,9 @@ class _DeleteAreaState extends ConsumerState : const Duration(milliseconds: 140); return Padding( - padding: const EdgeInsets.all(16), + // Same 8px off the strip as the map card on the left, so both hang + // from the same line. + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: CompositedTransformTarget( link: _layerLink, child: OverlayPortal( @@ -329,8 +332,8 @@ class _DeleteAreaState extends ConsumerState children: [ Center( child: Icon( - Icons.delete_outline, - size: 24, + LucideIcons.trash2, + size: 22, color: iconColor, ), ), diff --git a/lib/widgets/demo_dialog.dart b/lib/widgets/demo_dialog.dart index 5034a8f4..aba1aea8 100644 --- a/lib/widgets/demo_dialog.dart +++ b/lib/widgets/demo_dialog.dart @@ -21,7 +21,7 @@ class DemoDialog extends ConsumerWidget { actions: [ ShadButton.secondary( leading: const Icon( - Icons.close, + LucideIcons.x, ), onPressed: () { Navigator.of(context).pop(); @@ -29,7 +29,7 @@ class DemoDialog extends ConsumerWidget { child: const Text('Close'), ), ShadButton( - leading: const Icon(Icons.download), + leading: const Icon(LucideIcons.download), onPressed: () async { await launchUrl(Settings.stableWindowsInstallerLink); }, diff --git a/lib/widgets/demo_tag.dart b/lib/widgets/demo_tag.dart index 95881eef..bef152cf 100644 --- a/lib/widgets/demo_tag.dart +++ b/lib/widgets/demo_tag.dart @@ -43,7 +43,7 @@ class DemoTag extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.science_outlined, + LucideIcons.flaskConical, size: 14, color: Colors.white.withAlpha(230), // 0.9 ), diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart index 9a5df12b..bfa0c57a 100644 --- a/lib/widgets/dialogs/auth/auth_dialog.dart +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -309,7 +309,7 @@ class _AuthMessageBanner extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - isInfo ? Icons.mark_email_read_outlined : Icons.error_outline, + isInfo ? LucideIcons.mailCheck : LucideIcons.circleAlert, size: 15, color: color, ), diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index 3abf5a4a..c209c0ca 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -16,6 +16,7 @@ class CreateStrategyDialog extends ConsumerStatefulWidget { class _NameStrategyDialogState extends ConsumerState { final TextEditingController _textController = TextEditingController(); + bool _isSubmitting = false; @override void dispose() { @@ -23,80 +24,56 @@ class _NameStrategyDialogState extends ConsumerState { super.dispose(); } + Future _submit() async { + if (_isSubmitting) return; + final strategyName = _textController.text.trim(); + if (strategyName.isEmpty) { + Settings.showToast( + message: 'Strategy name cannot be empty.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + + final isCloud = + ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + setState(() => _isSubmitting = true); + try { + final strategyID = await ref + .read(strategyProvider.notifier) + .createNewStrategy(strategyName); + if (!mounted) return; + Navigator.of(context).pop(strategyID); + } catch (_) { + if (mounted) setState(() => _isSubmitting = false); + Settings.showToast( + message: isCloud + ? "Couldn't create cloud strategy right now. Please try logging in again." + : "Couldn't create strategy right now.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } + @override Widget build(BuildContext context) { - final isCloud = - ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; return ShadDialog( - title: const Text("Create Strategy"), + title: const Text('Create Strategy'), actions: [ ShadButton( - child: const Text("Create"), - onPressed: () async { - final strategyName = _textController.text; - if (strategyName.isNotEmpty) { - try { - final strategyID = await ref - .read(strategyProvider.notifier) - .createNewStrategy(strategyName); - if (!context.mounted) return; - Navigator.of(context).pop(strategyID); // Close the dialog - } catch (_) { - Settings.showToast( - message: isCloud - ? "Couldn't create cloud strategy right now. Please try logging in again." - : "Couldn't create strategy right now.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - } else { - // Optionally, show an error message if the name is empty - Settings.showToast( - message: "Strategy name cannot be empty.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - }, - ) + onPressed: _isSubmitting ? null : _submit, + child: Text(_isSubmitting ? 'Creating…' : 'Create'), + ), ], child: SizedBox( width: 300, child: CustomTextField( - // onEnterPressed: (intent) {}, - hintText: "Enter strategy name", + hintText: 'Enter strategy name', controller: _textController, - - onSubmitted: (value) async { - if (value.isNotEmpty) { - try { - final strategyID = await ref - .read(strategyProvider.notifier) - .createNewStrategy(value); - if (!context.mounted) return; - Navigator.of(context).pop(strategyID); // Close the dialog - } catch (_) { - Settings.showToast( - message: isCloud - ? "Couldn't create cloud strategy right now. Please try logging in again." - : "Couldn't create strategy right now.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - } else { - // Optionally, show an error message if the name is empty - Settings.showToast( - message: "Strategy name cannot be empty.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - }, + autofocus: true, + onSubmitted: (_) => _submit(), ), ), ); } } -// How to use it: -// showDialog( -// context: context, -// builder: (context) => const NameStrategyDialog(), -// ); diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index 0eea1215..d8407aa9 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -124,7 +124,7 @@ class _DeleteStrategyAlertDialogState mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.delete_forever, + LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructiveForeground, ), const SizedBox(width: 5), diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index 5a9d9c2b..2a095635 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -9,6 +9,7 @@ import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:path/path.dart' as path; +import 'package:shadcn_ui/shadcn_ui.dart'; class LineupMediaPage extends ConsumerStatefulWidget { final TextEditingController youtubeLinkController; @@ -113,7 +114,7 @@ class _LineupMediaPageState extends ConsumerState { child: Column( children: [ Icon( - Icons.add_photo_alternate_outlined, + LucideIcons.imagePlus, size: 48, color: Settings.tacticalVioletTheme.cardForeground, ), @@ -183,7 +184,7 @@ class _LineupMediaPageState extends ConsumerState { border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: Icon( - Icons.add, + LucideIcons.plus, color: Settings.tacticalVioletTheme.secondaryForeground, ), ), @@ -206,7 +207,7 @@ class _LineupMediaPageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.content_paste, + LucideIcons.clipboardPaste, color: Settings.tacticalVioletTheme.secondaryForeground, ), const SizedBox(height: 4), @@ -240,7 +241,7 @@ class _LineupMediaPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.content_paste, + LucideIcons.clipboardPaste, color: Settings.tacticalVioletTheme.secondaryForeground, size: 16, ), @@ -288,7 +289,7 @@ class _LineupMediaPageState extends ConsumerState { child: imageProvider == null ? Center( child: Icon( - Icons.broken_image, + LucideIcons.imageOff, color: Settings.tacticalVioletTheme.secondaryForeground, ), ) @@ -307,7 +308,7 @@ class _LineupMediaPageState extends ConsumerState { color: Colors.black54, shape: BoxShape.circle, ), - child: const Icon(Icons.close, size: 14, color: Colors.white), + child: const Icon(LucideIcons.x, size: 14, color: Colors.white), ), ), ), diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index 971d2aba..6db6cf03 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -71,16 +71,16 @@ class _RenameStrategyDialogState extends ConsumerState { } }, height: 35, - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.pencil), child: const Text("Rename"), ), ], child: Padding( padding: const EdgeInsets.all(8.0), child: CustomTextField( - // onEnterPressed: (intent) {}, hintText: widget.currentName, controller: _textController, + autofocus: true, textAlign: TextAlign.start, onSubmitted: (value) async { if (value.isNotEmpty) { diff --git a/lib/widgets/dialogs/upload_image_dialog.dart b/lib/widgets/dialogs/upload_image_dialog.dart index 0a68bed5..bf4859c1 100644 --- a/lib/widgets/dialogs/upload_image_dialog.dart +++ b/lib/widgets/dialogs/upload_image_dialog.dart @@ -333,9 +333,7 @@ class _EmptyState extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - isDragging - ? Icons.file_download_outlined - : Icons.add_photo_alternate_outlined, + isDragging ? LucideIcons.download : LucideIcons.imagePlus, size: 44, color: isDragging ? cs.primary : cs.onSurfaceVariant, ), @@ -404,7 +402,7 @@ class _SelectionFooter extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - const Icon(Icons.image_outlined, size: 18), + const Icon(LucideIcons.image, size: 18), const SizedBox(width: 8), Expanded( child: Text( diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 935ee176..552af07e 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -217,7 +217,8 @@ ShadContextMenuItem _buildToggleItem({ return ShadContextMenuItem( onPressed: onPressed, leading: Icon( - isEnabled ? Icons.check_box : Icons.check_box_outline_blank, + isEnabled ? LucideIcons.squareCheck : LucideIcons.square, + size: 16, ), child: Text(label), ); @@ -230,7 +231,8 @@ ShadContextMenuItem _buildDeleteItem( ) { return ShadContextMenuItem( leading: Icon( - Icons.delete, + LucideIcons.trash2, + size: 16, color: Settings.tacticalVioletTheme.destructive, ), child: const Text('Delete'), diff --git a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart index adaa4ca2..a4f29e28 100644 --- a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart +++ b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart @@ -16,7 +16,7 @@ List buildAdjacentPageCopyMenuItems( return [ if (directions.contains(PageTransitionDirection.backward)) ShadContextMenuItem( - leading: const Icon(Icons.arrow_upward), + leading: const Icon(LucideIcons.arrowUp, size: 16), child: const Text('Copy to previous page'), onPressed: () async { await notifier.copyPlacedWidgetToAdjacentPage( @@ -27,7 +27,7 @@ List buildAdjacentPageCopyMenuItems( ), if (directions.contains(PageTransitionDirection.forward)) ShadContextMenuItem( - leading: const Icon(Icons.arrow_downward), + leading: const Icon(LucideIcons.arrowDown, size: 16), child: const Text('Copy to next page'), onPressed: () async { await notifier.copyPlacedWidgetToAdjacentPage( diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index b73ae89a..8c181d54 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -241,7 +241,7 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && lineUpId != null) ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Add Lineup Item'), onPressed: () { final group = @@ -259,7 +259,8 @@ class AgentWidget extends ConsumerWidget { if (canInteract && lineUpId != null) ShadContextMenuItem( leading: Icon( - Icons.delete, + LucideIcons.trash2, + size: 16, color: Settings.tacticalVioletTheme.destructive, ), child: const Text('Delete Lineup Group'), @@ -269,7 +270,7 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && viewConeAgent != null) ShadContextMenuItem( - leading: const Icon(LucideIcons.eyeOff), + leading: const Icon(LucideIcons.eyeOff, size: 16), child: const Text('Remove View Cone'), onPressed: () { ref.read(actionProvider.notifier).performTransaction( @@ -287,7 +288,7 @@ class AgentWidget extends ConsumerWidget { plainAgent != null && plainAgent.id.isNotEmpty) ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Create Lineup'), onPressed: () { ref diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart index ef887d62..67e7f2e1 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart @@ -17,6 +17,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/custom_shape_resize_t import 'package:icarus/widgets/draggable_widgets/utilities/rectangle_axis_resize_geometry.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/shape_indicator_fade.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; enum _RectangleResizeHandle { none, left, right, top, bottom } @@ -841,7 +842,7 @@ class _RotationBadge extends StatelessWidget { curve: Curves.easeOutCubic, scale: isEmphasized ? 1.0 : 0.9, child: Icon( - Icons.rotate_right_rounded, + LucideIcons.rotateCw, size: size, color: isActive ? Settings.tacticalVioletTheme.primary : Colors.white, shadows: const [ diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart index 096e8503..f36d0f0d 100644 --- a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart +++ b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart @@ -9,7 +9,7 @@ ShadContextMenuItem buildViewConeElevationMenuItem({ required ValueChanged onChanged, }) { return ShadContextMenuItem( - leading: const Icon(Icons.layers_outlined), + leading: const Icon(LucideIcons.layers, size: 16), trailing: Text( selectedElevation == null ? 'Auto ${formatVisionElevation(automaticElevation)}' @@ -45,7 +45,7 @@ ShadContextMenuItem buildViewConeDebugMenuItem({ required ValueChanged onChanged, }) { return ShadContextMenuItem( - leading: Icon(enabled ? Icons.visibility : Icons.visibility_outlined), + leading: Icon(enabled ? LucideIcons.eye : LucideIcons.eyeOff, size: 16), trailing: Text(enabled ? 'On' : 'Off'), onPressed: () => onChanged(!enabled), child: const Text('Vision calibration'), @@ -59,7 +59,8 @@ ShadContextMenuItem _elevationItem({ }) { return ShadContextMenuItem( leading: Icon( - selected ? Icons.radio_button_checked : Icons.radio_button_off, + selected ? LucideIcons.circleDot : LucideIcons.circle, + size: 16, ), onPressed: onPressed, child: Text(label), diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart new file mode 100644 index 00000000..ea99b309 --- /dev/null +++ b/lib/widgets/editor_toolbar.dart @@ -0,0 +1,389 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/screenshot_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/screenshot/offscreen_capture.dart'; +import 'package:icarus/screenshot/screenshot_view.dart'; +import 'package:icarus/widgets/cloud_sync_button.dart'; +import 'package:icarus/widgets/dialogs/export_video_dialog.dart'; +import 'package:icarus/widgets/settings_tab.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Geometry shared by every control in the editor's floating toolbar, so the +/// save button (which swaps between local and cloud variants) matches its +/// neighbours exactly. +class EditorToolbarButtonStyle { + const EditorToolbarButtonStyle({ + this.size = 32, + this.iconSize = 18, + }); + + final double size; + final double iconSize; +} + +const EditorToolbarButtonStyle kEditorToolbarButtonStyle = + EditorToolbarButtonStyle(); + +/// The document actions of the open strategy, one card under the map card at +/// the top-left of the canvas: save, export, video, screenshot, then settings. +class EditorToolbar extends ConsumerStatefulWidget { + const EditorToolbar({super.key}); + + @override + ConsumerState createState() => _EditorToolbarState(); +} + +class _EditorToolbarState extends ConsumerState { + bool _isCapturingScreenshot = false; + + @override + Widget build(BuildContext context) { + const style = kEditorToolbarButtonStyle; + final source = ref.watch(strategyProvider.select((value) => value.source)); + final isCloud = source == StrategySource.cloud; + + return Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCloud) + const CloudSyncButton(style: style) + else + const AutoSaveButton(style: style), + EditorToolbarButton( + style: style, + tooltip: 'Export .ica', + onPressed: _exportStrategy, + icon: const Icon(LucideIcons.upload200), + ), + EditorToolbarButton( + style: style, + tooltip: 'Export video', + onPressed: _exportVideo, + icon: const Icon(LucideIcons.clapperboard200), + ), + EditorToolbarButton( + style: style, + tooltip: 'Screenshot', + onPressed: _captureScreenshot, + icon: _isCapturingScreenshot + ? SizedBox( + width: style.iconSize - 2, + height: style.iconSize - 2, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, + ), + ), + ) + : const Icon(LucideIcons.camera200), + ), + const EditorToolbarDivider(), + EditorToolbarButton( + style: style, + tooltip: 'Settings', + onPressed: () { + showShadDialog( + context: context, + builder: (context) => const SettingsTab(), + ); + }, + icon: const Icon(LucideIcons.settings200), + ), + ], + ), + ), + if (_isViewOnly()) ...[ + const SizedBox(width: 8), + const _ViewOnlyChip(), + ], + ], + ); + } + + void _showDesktopOnlyToast() { + Settings.showToast( + message: 'This feature is only supported in the desktop app.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + + Future _exportStrategy() async { + if (kIsWeb) { + _showDesktopOnlyToast(); + return; + } + + final strategy = ref.read(strategyProvider); + final strategyId = strategy.strategyId; + if (strategyId == null || strategy.source == null) { + throw StateError('No strategy is open for export.'); + } + + final exporter = StrategyImportExportService(ref); + switch (strategy.source!) { + case StrategySource.cloud: + await runCloudStrategyExport(ref, strategyId); + case StrategySource.local: + await exporter.exportFile(strategyId); + } + } + + void _exportVideo() { + if (kIsWeb) { + _showDesktopOnlyToast(); + return; + } + showShadDialog( + context: context, + builder: (context) => const ExportVideoDialog(), + ); + } + + Future _captureScreenshot() async { + if (kIsWeb) { + _showDesktopOnlyToast(); + return; + } + if (_isCapturingScreenshot) return; + setState(() => _isCapturingScreenshot = true); + CoordinateSystem.instance.setIsScreenshot(true); + + final String id = ref.read(strategyProvider).strategyId!; + + await ref.read(strategyProvider.notifier).forceSaveNow(id); + + final newStrat = Hive.box(HiveBoxNames.strategiesBox) + .values + .where((StrategyData strategy) => strategy.id == id) + .firstOrNull; + + if (newStrat == null) { + if (mounted) setState(() => _isCapturingScreenshot = false); + CoordinateSystem.instance.setIsScreenshot(false); + return; + } + final newController = ScreenshotController(); + final mapState = ref.read(mapProvider); + final currentPageID = ref.read(strategyPageSessionProvider).activePageId; + + if (currentPageID == null) { + if (mounted) setState(() => _isCapturingScreenshot = false); + CoordinateSystem.instance.setIsScreenshot(false); + return; + } + + final activePage = newStrat.pages.firstWhere( + (p) => p.id == currentPageID, + orElse: () => newStrat.pages.first, + ); + final screenshotContainer = ProviderContainer(); + + try { + final screenshotView = ScreenshotView( + isAttack: activePage.isAttack, + mapValue: newStrat.mapData, + showSpawnBarrier: mapState.showSpawnBarrier, + showRegionNames: mapState.showRegionNames, + showUltOrbs: mapState.showUltOrbs, + agents: activePage.agentData, + abilities: activePage.abilityData, + text: activePage.textData, + images: activePage.imageData, + drawings: activePage.drawingData, + utilities: activePage.utilityData, + strategySettings: activePage.settings, + strategyState: ref.read(strategyProvider), + pageName: activePage.name, + lineUpGroups: activePage.lineUpGroups, + themeProfileId: newStrat.themeProfileId, + themeOverridePalette: newStrat.themeOverridePalette, + ); + screenshotView.hydrateProviders(screenshotContainer); + final image = await newController.captureFromWidget( + targetSize: CoordinateSystem.screenShotSize, + wrapForOffscreenCapture( + screenshotView, + container: screenshotContainer, + ), + ); + if (mounted) setState(() => _isCapturingScreenshot = false); + String? outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: + "${ref.read(strategyProvider).strategyName ?? "new image"}.png", + allowedExtensions: ['png'], + ); + if (outputFile != null) { + final file = File(outputFile); + await file.writeAsBytes(image); + } + } catch (_) { + } finally { + screenshotContainer.dispose(); + if (mounted && _isCapturingScreenshot) { + setState(() => _isCapturingScreenshot = false); + } + ref.read(screenshotProvider.notifier).setIsScreenShot(false); + CoordinateSystem.instance.setIsScreenshot(false); + ref + .read(drawingProvider.notifier) + .rebuildAllPaths(CoordinateSystem.instance); + } + } + + bool _isViewOnly() { + final source = ref.watch(strategyProvider.select((value) => value.source)); + if (source != StrategySource.cloud) { + return false; + } + // Read the cached role rather than the raw snapshot so the chip does not + // flicker off while the snapshot is reloading or transiently errored; it + // is absent only before the role has ever been known. + final role = ref.watch(lastKnownCloudRoleProvider); + return role == 'viewer'; + } +} + +/// One control in the editor toolbar. Glyphs are the 200 stroke weight and +/// rest in [Settings.toolbarGlyph]: the default 2px Lucide stroke reads heavy +/// at 18px, and full white on top of it shouts, so the weight and a step of +/// grey share the quietness. Hover brings the glyph up to foreground. [icon] +/// is any 18px glyph, so buttons can swap in a spinner without changing size. +class EditorToolbarButton extends StatelessWidget { + const EditorToolbarButton({ + super.key, + required this.style, + required this.tooltip, + required this.icon, + required this.onPressed, + this.enabled = true, + this.foregroundColor, + this.semanticsLabel, + }); + + final EditorToolbarButtonStyle style; + final String tooltip; + final Widget icon; + final VoidCallback? onPressed; + final bool enabled; + + /// Overrides the resting color, e.g. destructive for a sync problem. + final Color? foregroundColor; + final String? semanticsLabel; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final resting = foregroundColor ?? Settings.toolbarGlyph; + return Semantics( + label: semanticsLabel ?? tooltip, + button: true, + enabled: enabled, + onTap: enabled ? onPressed : null, + excludeSemantics: true, + child: ShadTooltip( + builder: (context) => Text(tooltip), + child: IconTheme( + data: IconThemeData(size: style.iconSize, color: resting), + child: ShadIconButton.ghost( + width: style.size, + height: style.size, + enabled: enabled, + foregroundColor: resting, + hoverForegroundColor: foregroundColor ?? theme.foreground, + hoverBackgroundColor: theme.accent, + onPressed: onPressed, + icon: icon, + ), + ), + ), + ); + } +} + +/// A 1px hairline between groups of toolbar controls. +class EditorToolbarDivider extends StatelessWidget { + const EditorToolbarDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 18, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: Settings.tacticalVioletTheme.border, + ); + } +} + +/// Non-interactive chip shown beside the toolbar when the open cloud strategy +/// is shared with view-only access. +class _ViewOnlyChip extends StatelessWidget { + const _ViewOnlyChip(); + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + + return ShadTooltip( + builder: (context) => const Text( + 'You have view access. Ask the owner for edit access to make changes.', + ), + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: theme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.eye200, size: 14, color: theme.mutedForeground), + const SizedBox(width: 6), + Text( + 'View only', + style: TextStyle( + color: theme.mutedForeground, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + height: 1.2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 83a53f90..f22ba372 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -5,6 +5,9 @@ import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; @@ -49,8 +52,23 @@ class FolderCardViewData { mapPeeks = _collectMapPeeks(strategies), agentTypes = _collectAgents(strategies); + /// A folder summarised elsewhere, e.g. by the cloud: counts, map peeks, and + /// agents arrive ready-made instead of being derived from strategies here. + FolderCardViewData.summary({ + required this.folder, + required this.folderCount, + this.strategyCount, + List maps = const [], + this.agentTypes = const [], + }) : mapPeeks = [ + for (final map in maps.take(2)) + 'assets/maps/thumbnails/${Maps.mapNames[map]}_thumbnail.webp', + ]; + final Folder folder; - final int strategyCount; + + /// Null when the folder's strategies are not known here. + final int? strategyCount; final int folderCount; /// Distinct map thumbnail assets, most used first, capped at 2. @@ -115,10 +133,19 @@ class FolderCard extends ConsumerStatefulWidget { const FolderCard({ super.key, required this.data, + this.store = LibraryWorkspace.local, + this.cloudRole, this.isDemo = false, }); final FolderCardViewData data; + + /// Which library the folder belongs to. My Library lists both stores, so + /// the card cannot infer this from the active workspace. + final LibraryWorkspace store; + + /// The signed-in user's role on a cloud folder; null for local folders. + final String? cloudRole; final bool isDemo; @override @@ -140,6 +167,10 @@ class _FolderCardState extends ConsumerState final DragTiltController _dragTiltController = DragTiltController(); Folder get _folder => widget.data.folder; + bool get _isCloud => widget.store == LibraryWorkspace.cloud; + + /// Shared cloud folders can be opened but only their owner reshapes them. + bool get _canManage => !_isCloud || widget.cloudRole == 'owner'; @override void initState() { @@ -209,8 +240,13 @@ class _FolderCardState extends ConsumerState String? currentParentId = _folder.parentID; while (currentParentId != null) { if (currentParentId == folderId) return true; - final parentFolder = - ref.read(folderProvider.notifier).findFolderByID(currentParentId); + final folders = ref.read(folderProvider.notifier); + final parentFolder = _isCloud + ? folders.findCloudFolderByID( + currentParentId, + ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], + ) + : folders.findLocalFolderByID(currentParentId); currentParentId = parentFolder?.parentID; } return false; @@ -233,6 +269,8 @@ class _FolderCardState extends ConsumerState return DragTarget(onWillAcceptWithDetails: (details) { final item = details.data; if (widget.isDemo) return false; + if (!_canManage) return false; + if (item.store != widget.store) return false; if (item is FolderItem) { return item.folder.id != id && !_isParentFolder(item.folder.id); } @@ -283,13 +321,19 @@ class _FolderCardState extends ConsumerState } if (item is StrategyItem) { - await ref - .read(strategyProvider.notifier) - .moveToFolder(strategyID: item.strategy!.id, parentID: _folder.id); + await ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: _folder.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); } else if (item is FolderItem) { - await ref - .read(folderProvider.notifier) - .moveToFolder(folderID: item.folder.id, parentID: _folder.id); + await ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: _folder.id, + workspace: widget.store, + ); } }, builder: (context, candidateData, rejectedData) { final isPinnedDropTarget = candidateData.any( @@ -314,7 +358,7 @@ class _FolderCardState extends ConsumerState dragAnchorStrategy: pointerDragAnchorStrategy, onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(_folder, store: LibraryWorkspace.local), + data: FolderItem(_folder, store: widget.store), child: MouseRegion( onEnter: (_) { _isHovered = true; @@ -331,7 +375,10 @@ class _FolderCardState extends ConsumerState child: GestureDetector( onTap: () { if (widget.isDemo) return; - ref.read(folderProvider.notifier).updateID(_folder.id); + ref.read(folderProvider.notifier).openFolder( + folderId: _folder.id, + store: widget.store, + ); }, child: AnimatedBuilder( animation: _open, @@ -551,7 +598,7 @@ class _FolderCardState extends ConsumerState if (isPinned) ...[ const SizedBox(width: 4), Icon( - Icons.push_pin, + LucideIcons.pin, color: Colors.white.withValues(alpha: 0.78), size: 13, ), @@ -569,10 +616,11 @@ class _FolderCardState extends ConsumerState Expanded( child: agents.isEmpty ? Text( - widget.data.strategyCount == 0 - ? 'Empty' - : '${widget.data.strategyCount} ' - 'strateg${widget.data.strategyCount == 1 ? 'y' : 'ies'}', + switch (widget.data.strategyCount) { + null => '', + 0 => 'Empty', + final count => '$count strateg${count == 1 ? 'y' : 'ies'}', + }, style: TextStyle( color: Colors.white.withValues(alpha: 0.55), fontSize: 11, @@ -581,10 +629,11 @@ class _FolderCardState extends ConsumerState ) : _AgentComposition(agents: agents), ), - if (widget.data.strategyCount > 0 || widget.data.folderCount > 0) ...[ + if ((widget.data.strategyCount ?? 0) > 0 || + widget.data.folderCount > 0) ...[ const SizedBox(width: 6), _CountBadge( - strategyCount: widget.data.strategyCount, + strategyCount: widget.data.strategyCount ?? 0, folderCount: widget.data.folderCount, ), ], @@ -624,7 +673,7 @@ class _FolderCardState extends ConsumerState highlightColor: Colors.white.withValues(alpha: 0.08), onTap: _handleMenuButtonPressed, child: Icon( - Icons.more_vert, + LucideIcons.ellipsisVertical, color: Colors.white.withValues(alpha: iconAlpha), size: 16, ), @@ -639,7 +688,7 @@ class _FolderCardState extends ConsumerState final id = _folder.id; return [ ShadContextMenuItem( - leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), child: Text(isPinned ? 'Unpin' : 'Pin'), onPressed: () { _closeMenus(); @@ -648,22 +697,43 @@ class _FolderCardState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.pencil), + enabled: _canManage, + onPressed: !_canManage + ? null + : () async { + _closeMenus(); + if (widget.isDemo) return; + await showDialog( + context: context, + builder: (context) { + return FolderEditDialog( + folder: _folder, + store: widget.store, + ); + }, + ); + }, child: const Text('Edit'), - onPressed: () async { - _closeMenus(); - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog( - folder: _folder, store: LibraryWorkspace.local); - }, - ); - }, ), + if (_isCloud && widget.cloudRole == 'owner') + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + child: const Text('Share'), + onPressed: () async { + _closeMenus(); + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'folder', + targetPublicId: _folder.id, + title: _folder.name, + ), + ); + }, + ), ShadContextMenuItem( - leading: const Icon(Icons.file_upload), + leading: const Icon(LucideIcons.upload), child: const Text('Export'), onPressed: () async { _closeMenus(); @@ -671,19 +741,24 @@ class _FolderCardState extends ConsumerState }, ), ShadContextMenuItem( - leading: const Icon(Icons.delete, color: Colors.redAccent), - child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), - onPressed: () async { - _closeMenus(); - if (widget.isDemo) return; - await showShadDialog( - context: context, - builder: (_) => DeleteFolderAlertDialog( - folder: _folder, - workspace: LibraryWorkspace.local, - ), - ); - }, + leading: Icon(LucideIcons.trash2, + color: Settings.tacticalVioletTheme.destructive), + enabled: _canManage, + onPressed: !_canManage + ? null + : () async { + _closeMenus(); + if (widget.isDemo) return; + await showShadDialog( + context: context, + builder: (_) => DeleteFolderAlertDialog( + folder: _folder, + workspace: widget.store, + ), + ); + }, + child: Text('Delete', + style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), ), ]; } @@ -842,7 +917,7 @@ class _CountBadge extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (folderCount > 0) ...[ - Icon(Icons.folder_outlined, size: 12, color: muted), + Icon(LucideIcons.folder, size: 12, color: muted), const SizedBox(width: 2), Text( '$folderCount', @@ -852,7 +927,7 @@ class _CountBadge extends StatelessWidget { ], if (folderCount > 0 && strategyCount > 0) const SizedBox(width: 6), if (strategyCount > 0) ...[ - Icon(Icons.description_outlined, size: 12, color: muted), + Icon(LucideIcons.fileText, size: 12, color: muted), const SizedBox(width: 2), Text( '$strategyCount', diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 1fc2a0d3..40141be0 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -22,7 +22,7 @@ import 'package:icarus/widgets/library_entries.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; -import 'package:icarus/widgets/folder_pill.dart'; +import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/hover_dot_grid.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; @@ -217,6 +217,17 @@ class FolderContent extends ConsumerWidget { allFolders: allFolders, allStrategies: allStrategies, ), + card: FolderCardViewData( + folder: item, + strategies: strategiesInFolderTree( + folder: item, + allFolders: allFolders, + allStrategies: allStrategies, + ), + folderCount: allFolders + .where((folder) => folder.parentID == item.id) + .length, + ), ), ]; final strategies = [ @@ -260,7 +271,8 @@ class FolderContent extends ConsumerWidget { /// store. Widget _buildLibraryRoot(BuildContext context, WidgetRef ref) { final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); - final foldersAsync = cloudAvailable ? ref.watch(cloudFoldersProvider) : null; + final foldersAsync = + cloudAvailable ? ref.watch(cloudFoldersProvider) : null; final strategiesAsync = cloudAvailable ? ref.watch(cloudStrategiesProvider) : null; // Only the very first fetch shows the skeleton; dependency changes keep @@ -272,15 +284,12 @@ class FolderContent extends ConsumerWidget { if (isInitialLoading) { return const _LibraryLoadingSkeleton(key: ValueKey('cloud-loading')); } - final cloudFailed = - (foldersAsync?.hasError ?? false) || (strategiesAsync?.hasError ?? false); + final cloudFailed = (foldersAsync?.hasError ?? false) || + (strategiesAsync?.hasError ?? false); final cloudFolders = [ - for (final entry in foldersAsync?.valueOrNull ?? const []) - LibraryFolderRow( - folder: entry.folder, - store: LibraryWorkspace.cloud, - lastUpdated: entry.folder.dateCreated, - ), + for (final entry + in foldersAsync?.valueOrNull ?? const []) + _cloudFolderRow(ref, entry), ]; final cloudStrategies = [ for (final entry @@ -308,9 +317,11 @@ class FolderContent extends ConsumerWidget { ), ), acceptsIcaDrops: true, - banner: cloudFailed ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) : null, + banner: cloudFailed + ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) + : null, emptyStateKey: const ValueKey('library-empty-state'), - emptyStateIcon: Icons.folder_outlined, + emptyStateIcon: LucideIcons.folder, emptyStateTitle: 'Your library is empty', emptyStateSubtitle: cloudAvailable ? 'Create your first strategy to keep it available across your ' @@ -320,7 +331,7 @@ class FolderContent extends ConsumerWidget { emptyStateAction: ShadButton( key: const ValueKey('library-empty-create-strategy'), onPressed: onCreateStrategy, - leading: const Icon(Icons.add), + leading: const Icon(LucideIcons.plus), child: const Text('Create Strategy'), ), ), @@ -328,6 +339,25 @@ class FolderContent extends ConsumerWidget { ); } + LibraryFolderRow _cloudFolderRow(WidgetRef ref, CloudFolderEntry entry) { + final tree = ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []; + return LibraryFolderRow( + folder: entry.folder, + store: LibraryWorkspace.cloud, + lastUpdated: entry.folder.dateCreated, + cloudRole: entry.role, + card: FolderCardViewData.summary( + folder: entry.folder, + folderCount: tree + .where((item) => item.folder.parentID == entry.folder.id) + .length, + strategyCount: entry.strategyCount, + maps: entry.mapPeeks, + agentTypes: entry.agentTypes, + ), + ); + } + void _retryCloud(WidgetRef ref) { ref.invalidate(cloudFolderTreeProvider); ref.invalidate(cloudStrategiesProvider); @@ -360,12 +390,9 @@ class FolderContent extends ConsumerWidget { ); } final folders = [ - for (final entry in foldersAsync.valueOrNull ?? const []) - LibraryFolderRow( - folder: entry.folder, - store: LibraryWorkspace.cloud, - lastUpdated: entry.folder.dateCreated, - ), + for (final entry + in foldersAsync.valueOrNull ?? const []) + _cloudFolderRow(ref, entry), ]; final strategies = [ for (final entry @@ -384,9 +411,8 @@ class FolderContent extends ConsumerWidget { emptyStateKey: isSharedWithMe && folder == null ? const ValueKey('shared-empty-state') : null, - emptyStateIcon: isSharedWithMe && folder == null - ? Icons.people_outline - : null, + emptyStateIcon: + isSharedWithMe && folder == null ? LucideIcons.users : null, emptyStateTitle: isSharedWithMe && folder == null ? 'Nothing shared with you yet' : 'No strategies in this folder', @@ -407,7 +433,7 @@ class FolderContent extends ConsumerWidget { : ShadButton( key: const ValueKey('cloud-empty-create-strategy'), onPressed: onCreateStrategy, - leading: const Icon(Icons.add), + leading: const Icon(LucideIcons.plus), child: const Text('Create Strategy'), ), ), @@ -517,16 +543,24 @@ class FolderContent extends ConsumerWidget { if (folders.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + // Each card carries half the gutter as drop hit area, so + // the row's padding shrinks by that much to keep x=16. + padding: const EdgeInsets.fromLTRB( + 16 - folderCardGutterOutset, + 16, + 16 - folderCardGutterOutset, + 8, + ), child: Wrap( - spacing: 10, - runSpacing: 10, + spacing: 0, + runSpacing: 14, children: [ for (final row in folders) - FolderPill( + FolderCard( key: ValueKey(row.id), - folder: row.folder, + data: row.card, store: row.store, + cloudRole: row.cloudRole, ), ], ), @@ -595,7 +629,7 @@ class FolderContent extends ConsumerWidget { children: [ if (currentFolder != null) Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: LibraryBreadcrumb(folder: currentFolder), ), if (banner != null) banner, @@ -639,7 +673,7 @@ class FolderContent extends ConsumerWidget { Widget _buildCloudUnavailableState(BuildContext context, WidgetRef ref) { return _LibraryMessageState( - icon: Icons.cloud_off_outlined, + icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.mutedForeground, title: 'Cloud unavailable', subtitle: 'Sign in again to reach your online strategies, or go back ' @@ -664,7 +698,7 @@ class FolderContent extends ConsumerWidget { Widget _buildCloudErrorState(BuildContext context, WidgetRef ref) { return _LibraryMessageState( - icon: Icons.cloud_off_outlined, + icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.destructive, title: "Couldn't load your cloud library", subtitle: 'Check your connection and try again.', @@ -684,7 +718,7 @@ class FolderContent extends ConsumerWidget { Widget _buildCommunityPlaceholder(BuildContext context, WidgetRef ref) { return _LibraryMessageState( - icon: Icons.public, + icon: LucideIcons.globe, iconColor: Settings.tacticalVioletTheme.primary, title: 'Community strats are coming soon', subtitle: @@ -721,7 +755,7 @@ class _CloudErrorBanner extends StatelessWidget { child: Row( children: [ Icon( - Icons.cloud_off_outlined, + LucideIcons.cloudOff, size: 16, color: Settings.tacticalVioletTheme.destructive, ), diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index ed8a1f42..91efbadd 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -134,6 +134,10 @@ class _FolderEditDialogState extends ConsumerState { child: CustomTextField( hintText: "Folder Name", controller: _folderNameController, + autofocus: true, + onSubmitted: (_) { + if (!_isSubmitting) _submit(); + }, ), ), // const SizedBox(width: 30), @@ -141,7 +145,7 @@ class _FolderEditDialogState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( key: const ValueKey('folder-edit-submit'), - leading: _isSubmitting ? null : const Icon(Icons.check), + leading: _isSubmitting ? null : const Icon(LucideIcons.check), onPressed: _isSubmitting ? null : _submit, child: Text(_isSubmitting ? 'Saving...' : 'Done'), ), diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 303c00c7..0fa30de7 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -320,7 +320,6 @@ class _FolderNavigatorState extends ConsumerState { onImportBackup: handleImportBackup, onExportLibrary: handleExportLibrary, ), - if (tab != LibraryTab.community) const CloudOutboxSummaryBanner(), Expanded( child: ShadContextMenuRegion( controller: _backgroundMenuController, @@ -328,13 +327,12 @@ class _FolderNavigatorState extends ConsumerState { ? const [] : [ ShadContextMenuItem( - leading: - const Icon(Icons.create_new_folder_outlined), + leading: const Icon(LucideIcons.folderPlus), onPressed: showCreateFolderDialog, child: const Text('Create Folder'), ), ShadContextMenuItem( - leading: const Icon(Icons.note_add_outlined), + leading: const Icon(LucideIcons.filePlus), onPressed: showCreateDialog, child: const Text('Create Strategy'), ), @@ -352,6 +350,14 @@ class _FolderNavigatorState extends ConsumerState { ], ), ), + // Cloud sync status floats over the library's corner so it never + // pushes the grid. + if (tab != LibraryTab.community) + const Positioned( + right: 16, + bottom: 16, + child: CloudOutboxSummaryBanner(), + ), if (_desktopUpdaterController != null) DesktopUpdateDialogListener( controller: _desktopUpdaterController!, diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart deleted file mode 100644 index c028dc41..00000000 --- a/lib/widgets/folder_pill.dart +++ /dev/null @@ -1,520 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/folder_icons.dart'; -import 'package:icarus/providers/collab/remote_library_provider.dart'; -import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/library_context_menu_provider.dart'; -import 'package:icarus/providers/library_workspace_provider.dart'; -import 'package:icarus/providers/pinned_items_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; -import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; -import 'package:icarus/widgets/drag_tilt_feedback.dart'; -import 'package:icarus/widgets/drop_insertion_indicator.dart'; -import 'package:icarus/widgets/folder_edit_dialog.dart'; -import 'package:icarus/widgets/folder_navigator.dart'; -import 'package:icarus/widgets/overflow_tooltip_text.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -const double _folderPillCornerRadius = 8; -const double _folderPillMenuButtonRadius = 6; - -class FolderPill extends ConsumerStatefulWidget { - const FolderPill({ - super.key, - required this.folder, - required this.store, - this.isDemo = false, - this.strategyCount, - this.folderCount, - }); - - final Folder folder; - - /// Which library the folder belongs to. My Library lists both stores, so - /// the pill cannot infer this from the active workspace. - final LibraryWorkspace store; - final bool isDemo; - final int? strategyCount; - final int? folderCount; - - @override - ConsumerState createState() => _FolderPillState(); -} - -class _FolderPillState extends ConsumerState - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _scaleAnimation; - bool _isHovered = false; - bool _isMenuButtonHovered = false; - bool _menuButtonWasOpenOnPointerDown = false; - DropInsertionSide? _pinnedDropSide; - final ShadContextMenuController _contextMenuController = - ShadContextMenuController(); - final ShadContextMenuController _rightClickMenuController = - ShadContextMenuController(); - final DragTiltController _dragTiltController = DragTiltController(); - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 150), - vsync: this, - ); - - _scaleAnimation = Tween( - begin: 1.0, - end: 1.03, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOut, - )); - } - - @override - void dispose() { - _animationController.dispose(); - _contextMenuController.dispose(); - _rightClickMenuController.dispose(); - super.dispose(); - } - - void _closeMenus() { - _contextMenuController.hide(); - _rightClickMenuController.hide(); - } - - void _handleMenuButtonPressed() { - if (_menuButtonWasOpenOnPointerDown) { - _menuButtonWasOpenOnPointerDown = false; - _closeMenus(); - return; - } - - dismissLibraryContextMenus(ref); - _contextMenuController.show(); - } - - Color get _folderColor => - widget.folder.customColor ?? - Folder.folderColorMap[widget.folder.color] ?? - Colors.grey; - - bool get _isCloudWorkspace => widget.store == LibraryWorkspace.cloud; - - String? get _cloudRole { - if (!_isCloudWorkspace) { - return null; - } - final allFolders = - ref.read(cloudAllFoldersProvider).valueOrNull ?? const []; - return allFolders - .where((entry) => entry.folder.id == widget.folder.id) - .map((entry) => entry.role) - .firstOrNull; - } - - bool get _canManageCloudFolder => !_isCloudWorkspace || _cloudRole == 'owner'; - - bool _isPinnedFolderReorderCandidate( - GridItem item, - Map pinned, - String id, - bool isPinned, - ) { - return item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id); - } - - @override - Widget build(BuildContext context) { - ref.listen( - libraryContextMenuDismissalProvider, - (_, __) => _closeMenus(), - ); - - final pinned = ref.watch(pinnedItemsProvider); - final id = widget.folder.id; - final isPinned = pinned.containsKey(id); - - return Draggable( - feedback: TiltDragFeedback( - controller: _dragTiltController, - opacity: 0.9, - child: _buildDragFeedback(), - ), - dragAnchorStrategy: pointerDragAnchorStrategy, - onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(widget.folder, store: widget.store), - child: DragTarget( - onWillAcceptWithDetails: (details) { - final item = details.data; - if (widget.isDemo) return false; - if (!_canManageCloudFolder) return false; - if (item.store != widget.store) return false; - if (item is FolderItem) { - return item.folder.id != id && !_isParentFolder(item.folder.id); - } - return true; - }, - onMove: (details) { - final item = details.data; - final nextSide = - _isPinnedFolderReorderCandidate(item, pinned, id, isPinned) - ? resolveDropInsertionSide( - context: context, - globalOffset: details.offset, - current: _pinnedDropSide, - ) - : null; - if (nextSide != _pinnedDropSide) { - setState(() => _pinnedDropSide = nextSide); - } - }, - onLeave: (_) { - if (_pinnedDropSide != null) { - setState(() => _pinnedDropSide = null); - } - }, - onAcceptWithDetails: (details) async { - if (widget.isDemo) return; - final item = details.data; - if (item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id)) { - final insertionSide = _pinnedDropSide ?? - resolveDropInsertionSide( - context: context, - globalOffset: details.offset, - ); - if (mounted) { - setState(() => _pinnedDropSide = null); - } - if (insertionSide == null) return; - - await ref.read(pinnedItemsProvider.notifier).movePin( - id: item.folder.id, - targetId: id, - insertAfterTarget: insertionSide == DropInsertionSide.after, - ); - return; - } - - if (item is StrategyItem) { - await ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: widget.folder.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - await ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, - parentID: widget.folder.id, - workspace: widget.store, - ); - } - }, - builder: (context, candidateData, rejectedData) { - final isPinnedDropTarget = candidateData.any( - (item) => - item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id), - ); - final isDropTarget = candidateData.isNotEmpty; - final isMoveIntoFolderTarget = isDropTarget && !isPinnedDropTarget; - final isHoverActive = _isHovered && !isPinnedDropTarget; - return MouseRegion( - onEnter: (_) { - setState(() => _isHovered = true); - _animationController.forward(); - }, - onExit: (_) { - setState(() => _isHovered = false); - _animationController.reverse(); - }, - cursor: SystemMouseCursors.click, - child: ShadContextMenuRegion( - controller: _rightClickMenuController, - items: _buildMenuItems(isPinned: isPinned), - child: GestureDetector( - onTap: () { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).openFolder( - folderId: widget.folder.id, - store: widget.store, - ); - }, - child: AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - final dropSide = _pinnedDropSide; - final slotKey = dropSide == null - ? null - : dropInsertionSlotKey( - itemId: id, - side: dropSide, - pinnedOrder: pinnedIdsInManualOrder(pinned), - ); - return Transform.scale( - scale: isPinnedDropTarget ? 1 : _scaleAnimation.value, - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: 44, - padding: const EdgeInsets.only(left: 14, right: 6), - decoration: BoxDecoration( - color: _folderColor, - borderRadius: BorderRadius.circular( - _folderPillCornerRadius), - border: Border.all( - color: isMoveIntoFolderTarget - ? Colors.white - : (isHoverActive - ? Colors.white.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.15)), - width: isMoveIntoFolderTarget ? 2 : 1, - ), - boxShadow: [ - BoxShadow( - color: _folderColor.withValues(alpha: 0.3), - blurRadius: isHoverActive ? 8 : 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FolderIconView( - iconId: widget.folder.iconId, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 140), - child: OverflowTooltipText( - widget.folder.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isPinned) ...[ - const SizedBox(width: 6), - Icon( - Icons.push_pin, - color: Colors.white.withValues(alpha: 0.78), - size: 14, - ), - ], - const SizedBox(width: 4), - _buildMenuButton(isPinned: isPinned), - ], - ), - ), - if (dropSide != null && slotKey != null) - Positioned.fill( - child: DropInsertionIndicator( - key: ValueKey(slotKey), - slotKey: slotKey, - side: dropSide, - gap: 18, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); - }, - ), - ); - } - - Widget _buildMenuButton({required bool isPinned}) { - final backgroundAlpha = _isMenuButtonHovered ? 0.16 : 0.04; - final iconAlpha = _isMenuButtonHovered ? 0.96 : 0.74; - - return ShadContextMenuRegion( - controller: _contextMenuController, - items: _buildMenuItems(isPinned: isPinned), - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _isMenuButtonHovered = true), - onExit: (_) => setState(() => _isMenuButtonHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: backgroundAlpha), - borderRadius: BorderRadius.circular(_folderPillMenuButtonRadius), - ), - child: Listener( - onPointerDown: (_) { - _menuButtonWasOpenOnPointerDown = _contextMenuController.isOpen; - }, - child: InkWell( - borderRadius: BorderRadius.circular(_folderPillMenuButtonRadius), - mouseCursor: SystemMouseCursors.click, - hoverColor: Colors.transparent, - splashColor: Colors.white.withValues(alpha: 0.12), - highlightColor: Colors.white.withValues(alpha: 0.08), - onTap: _handleMenuButtonPressed, - child: Icon( - Icons.more_vert, - color: Colors.white.withValues(alpha: iconAlpha), - size: 18, - ), - ), - ), - ), - ), - ); - } - - List _buildMenuItems({required bool isPinned}) { - final id = widget.folder.id; - return [ - ShadContextMenuItem( - leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), - child: Text(isPinned ? 'Unpin' : 'Pin'), - onPressed: () { - _closeMenus(); - if (widget.isDemo) return; - ref.read(pinnedItemsProvider.notifier).togglePin(id); - }, - ), - ShadContextMenuItem( - leading: const Icon(Icons.text_fields), - child: const Text('Edit'), - onPressed: !_canManageCloudFolder - ? null - : () async { - _closeMenus(); - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog( - folder: widget.folder, - store: widget.store, - ); - }, - ); - }, - ), - if (_isCloudWorkspace && _cloudRole == 'owner') - ShadContextMenuItem( - leading: const Icon(LucideIcons.link2), - child: const Text('Share'), - onPressed: () async { - await showShadDialog( - context: context, - builder: (_) => ShareLinksDialog( - targetType: 'folder', - targetPublicId: widget.folder.id, - title: widget.folder.name, - ), - ); - }, - ), - ShadContextMenuItem( - leading: const Icon(Icons.file_upload), - child: const Text('Export'), - onPressed: () async { - _closeMenus(); - await StrategyImportExportService(ref).exportFolder(widget.folder.id); - }, - ), - ShadContextMenuItem( - leading: const Icon(Icons.delete, color: Colors.redAccent), - child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), - onPressed: !_canManageCloudFolder - ? null - : () async { - _closeMenus(); - if (widget.isDemo) return; - await showShadDialog( - context: context, - builder: (_) => DeleteFolderAlertDialog( - folder: widget.folder, - workspace: widget.store, - ), - ); - }, - ), - ]; - } - - Widget _buildDragFeedback() { - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: _folderColor, - borderRadius: BorderRadius.circular(_folderPillCornerRadius), - border: Border.all(color: Colors.white, width: 2), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FolderIconView( - iconId: widget.folder.iconId, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - Text( - widget.folder.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } - - bool _isParentFolder(String folderId) { - String? currentParentId = widget.folder.parentID; - while (currentParentId != null) { - if (currentParentId == folderId) return true; - final parentFolder = widget.store == LibraryWorkspace.local - ? ref - .read(folderProvider.notifier) - .findLocalFolderByID(currentParentId) - : ref.read(folderProvider.notifier).findCloudFolderByID( - currentParentId, - ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], - ); - currentParentId = parentFolder?.parentID; - } - return false; - } -} - -extension on Iterable { - String? get firstOrNull => isEmpty ? null : first; -} diff --git a/lib/widgets/ica_drop_target.dart b/lib/widgets/ica_drop_target.dart index d8cca8d2..7b3163c1 100644 --- a/lib/widgets/ica_drop_target.dart +++ b/lib/widgets/ica_drop_target.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; String buildImportSummaryMessage(ImportBatchResult result) { final skippedCount = result.issues.length; @@ -144,7 +145,7 @@ class _CustomDropTargetState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.download, size: 60), + Icon(LucideIcons.download, size: 60), SizedBox( height: 10, ), diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index 17ec4a60..47d490bc 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class ImageDropTarget extends ConsumerStatefulWidget { const ImageDropTarget({super.key, required this.child}); @@ -85,7 +86,7 @@ class _ImageDropTargetState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.download, size: 60), + Icon(LucideIcons.download, size: 60), SizedBox( height: 10, ), diff --git a/lib/widgets/inset_shadow_decoration.dart b/lib/widgets/inset_shadow_decoration.dart new file mode 100644 index 00000000..6707228d --- /dev/null +++ b/lib/widgets/inset_shadow_decoration.dart @@ -0,0 +1,232 @@ +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; + +/// A shadow cast inward from the edge of a box, like CSS `box-shadow: inset`. +/// +/// With [blurRadius] zero this is a crisp inner edge: a 1px [spreadRadius] +/// draws a rim all the way round, a 1px [offset] draws a lit or shaded edge on +/// one side. Flutter's [BoxShadow] cannot do either, so this pairs with +/// [InsetShadowDecoration]. +class InsetShadow { + const InsetShadow({ + required this.color, + this.offset = Offset.zero, + this.blurRadius = 0, + this.spreadRadius = 0, + }); + + final Color color; + final Offset offset; + final double blurRadius; + final double spreadRadius; + + InsetShadow _faded() => InsetShadow( + color: color.withValues(alpha: 0), + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, + ); + + static InsetShadow lerp(InsetShadow a, InsetShadow b, double t) { + return InsetShadow( + color: Color.lerp(a.color, b.color, t)!, + offset: Offset.lerp(a.offset, b.offset, t)!, + blurRadius: ui.lerpDouble(a.blurRadius, b.blurRadius, t)!, + spreadRadius: ui.lerpDouble(a.spreadRadius, b.spreadRadius, t)!, + ); + } + + /// Lerps two lists, fading in or out whatever one side lacks. + static List lerpList( + List a, + List b, + double t, + ) { + final length = a.length > b.length ? a.length : b.length; + return [ + for (var i = 0; i < length; i++) + lerp( + i < a.length ? a[i] : b[i]._faded(), + i < b.length ? b[i] : a[i]._faded(), + t, + ), + ]; + } +} + +/// A rounded box with a fill (flat or [gradient]), optional border, outer +/// [boxShadows], and any number of [InsetShadow]s painted inside its edge. +/// +/// Each shadow uses the technique Chromium uses for inset shadows: clip to +/// the box, fill the plane minus a copy of the box that has been moved by the +/// shadow's offset and shrunk by its spread, then blur that ring. The border +/// paints last so it stays crisp over the shadows. +class InsetShadowDecoration extends Decoration { + const InsetShadowDecoration({ + this.color, + this.gradient, + this.borderRadius = BorderRadius.zero, + this.border, + this.boxShadows = const [], + this.shadows = const [], + }); + + final Color? color; + + /// Painted over [color] when set, so a gradient can sit on a base fill. + final Gradient? gradient; + final BorderRadius borderRadius; + final BoxBorder? border; + + /// Ordinary drop shadows, painted outside the box before the fill. + final List boxShadows; + final List shadows; + + @override + EdgeInsetsGeometry get padding => border?.dimensions ?? EdgeInsets.zero; + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + return Path()..addRRect(borderRadius.toRRect(rect)); + } + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) { + return borderRadius.toRRect(Offset.zero & size).contains(position); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _InsetShadowPainter(this, onChanged); + } + + // Animated containers can tween to and from this decoration, including + // from a plain rounded [BoxDecoration], so a selected state can fade in. + @override + Decoration? lerpFrom(Decoration? a, double t) { + final from = _coerce(a); + return from == null ? null : _lerp(from, this, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + final to = _coerce(b); + return to == null ? null : _lerp(this, to, t); + } + + static InsetShadowDecoration? _coerce(Decoration? other) { + if (other == null) return const InsetShadowDecoration(); + if (other is InsetShadowDecoration) return other; + if (other is BoxDecoration && + other.shape == BoxShape.rectangle && + other.image == null && + other.backgroundBlendMode == null && + (other.borderRadius == null || other.borderRadius is BorderRadius) && + (other.border == null || other.border is Border)) { + return InsetShadowDecoration( + color: other.color, + gradient: other.gradient, + borderRadius: + (other.borderRadius as BorderRadius?) ?? BorderRadius.zero, + border: other.border, + boxShadows: other.boxShadow ?? const [], + ); + } + return null; + } + + static InsetShadowDecoration _lerp( + InsetShadowDecoration a, + InsetShadowDecoration b, + double t, + ) { + return InsetShadowDecoration( + color: Color.lerp(a.color, b.color, t), + gradient: Gradient.lerp(a.gradient, b.gradient, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t)!, + border: BoxBorder.lerp(a.border, b.border, t), + boxShadows: BoxShadow.lerpList(a.boxShadows, b.boxShadows, t) ?? const [], + shadows: InsetShadow.lerpList(a.shadows, b.shadows, t), + ); + } +} + +class _InsetShadowPainter extends BoxPainter { + _InsetShadowPainter(this.decoration, super.onChanged); + + final InsetShadowDecoration decoration; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null) { + return; + } + final rect = offset & size; + final rrect = decoration.borderRadius.toRRect(rect); + + for (final shadow in decoration.boxShadows) { + canvas.drawRRect( + rrect.shift(shadow.offset).inflate(shadow.spreadRadius), + shadow.toPaint(), + ); + } + + final color = decoration.color; + if (color != null) { + canvas.drawRRect(rrect, Paint()..color = color); + } + final gradient = decoration.gradient; + if (gradient != null) { + canvas.drawRRect( + rrect, + Paint() + ..shader = gradient.createShader( + rect, + textDirection: configuration.textDirection, + ), + ); + } + + for (final shadow in decoration.shadows) { + _paintShadow(canvas, rect, rrect, shadow); + } + + decoration.border?.paint( + canvas, + rect, + textDirection: configuration.textDirection, + borderRadius: decoration.borderRadius, + ); + } + + void _paintShadow(Canvas canvas, Rect rect, RRect rrect, InsetShadow shadow) { + // The hole is the box itself, moved by the offset and shrunk by the + // spread; whatever it no longer covers inside the clip is the shadow. + final hole = rrect.shift(shadow.offset).deflate(shadow.spreadRadius); + // Extend the filled area past the clip so the blur never fades at the + // box edge, only at the hole edge. + final reach = shadow.blurRadius + shadow.spreadRadius; + final outer = rect.inflate(reach + shadow.offset.distance + 1); + + final ring = Path() + ..fillType = PathFillType.evenOdd + ..addRect(outer) + ..addRRect(hole); + + final paint = Paint()..color = shadow.color; + if (shadow.blurRadius > 0) { + paint.maskFilter = ui.MaskFilter.blur( + ui.BlurStyle.normal, + Shadow.convertRadiusToSigma(shadow.blurRadius), + ); + } + + canvas + ..save() + ..clipRRect(rrect) + ..drawPath(ring, paint) + ..restore(); + } +} diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart index 1f263295..72a476b0 100644 --- a/lib/widgets/library_breadcrumb.dart +++ b/lib/widgets/library_breadcrumb.dart @@ -34,47 +34,62 @@ class LibraryBreadcrumb extends ConsumerWidget { } } - return Row( - children: [ - ShadIconButton.ghost( - width: 30, - height: 30, - foregroundColor: Settings.tacticalVioletTheme.mutedForeground, - onPressed: () { - if (parent == null) { - goToRoot(); - } else { - ref.read(folderProvider.notifier).updateID(parent.id); - } - }, - icon: const Icon(Icons.chevron_left, size: 20), + // Same card as the editor toolbar, so the path reads as hardware on the + // bench instead of text floating on the dot grid. + return Align( + alignment: Alignment.centerLeft, + child: Container( + height: 36, + padding: const EdgeInsets.fromLTRB(4, 0, 12, 0), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), ), - const SizedBox(width: 4), - Expanded( - child: ShadBreadcrumb( - lastItemTextColor: Settings.tacticalVioletTheme.foreground, - textStyle: ShadTheme.of(context).textTheme.lead, - children: [ - FolderTab( - folder: null, - label: - tab == LibraryTab.shared ? 'Shared with Me' : 'My Library', - store: store, - onOpen: goToRoot, - ), - for (int i = 0; i < pathFolders.length; i++) + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShadIconButton.ghost( + width: 28, + height: 28, + foregroundColor: Settings.tacticalVioletTheme.mutedForeground, + hoverForegroundColor: Settings.tacticalVioletTheme.foreground, + onPressed: () { + if (parent == null) { + goToRoot(); + } else { + ref.read(folderProvider.notifier).updateID(parent.id); + } + }, + icon: const Icon(LucideIcons.chevronLeft300, size: 18), + ), + const SizedBox(width: 4), + ShadBreadcrumb( + lastItemTextColor: Settings.tacticalVioletTheme.foreground, + textStyle: ShadTheme.of(context).textTheme.small, + children: [ FolderTab( - folder: pathFolders[i], + folder: null, + label: tab == LibraryTab.shared + ? 'Shared with Me' + : 'My Library', store: store, - isActive: i == pathFolders.length - 1, - onOpen: () => ref - .read(folderProvider.notifier) - .updateID(pathFolders[i].id), + onOpen: goToRoot, ), - ], - ), + for (int i = 0; i < pathFolders.length; i++) + FolderTab( + folder: pathFolders[i], + store: store, + isActive: i == pathFolders.length - 1, + onOpen: () => ref + .read(folderProvider.notifier) + .updateID(pathFolders[i].id), + ), + ], + ), + ], ), - ], + ), ); } @@ -126,7 +141,7 @@ class FolderTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ShadBreadcrumbLink( - textStyle: ShadTheme.of(context).textTheme.lead, + textStyle: ShadTheme.of(context).textTheme.small, normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null, onPressed: onOpen, child: DragTarget( diff --git a/lib/widgets/library_entries.dart b/lib/widgets/library_entries.dart index 58374da8..52ef4932 100644 --- a/lib/widgets/library_entries.dart +++ b/lib/widgets/library_entries.dart @@ -1,6 +1,7 @@ import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/domain/folder.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -11,11 +12,20 @@ class LibraryFolderRow { required this.folder, required this.store, required this.lastUpdated, + required this.card, + this.cloudRole, }); final Folder folder; final LibraryWorkspace store; + /// What the folder card shows: contents for local folders, the subfolder + /// count alone for cloud folders (their strategies load per folder). + final FolderCardViewData card; + + /// The signed-in user's role on a cloud folder; null for local folders. + final String? cloudRole; + /// Newest edit inside the folder tree, used for the "Date updated" sort. /// Cloud folders do not carry this yet and fall back to their creation date. final DateTime lastUpdated; @@ -25,7 +35,8 @@ class LibraryFolderRow { /// One strategy in the library grid, from either store. class LibraryStrategyRow { - LibraryStrategyRow.local(StrategyData strategy, {required this.showDeviceBadge}) + LibraryStrategyRow.local(StrategyData strategy, + {required this.showDeviceBadge}) : local = strategy, cloud = null; @@ -84,7 +95,8 @@ List sortLibraryFolders( final result = switch (filter.sortBy) { SortBy.alphabetical => a.folder.name.toLowerCase().compareTo(b.folder.name.toLowerCase()), - SortBy.dateCreated => a.folder.dateCreated.compareTo(b.folder.dateCreated), + SortBy.dateCreated => + a.folder.dateCreated.compareTo(b.folder.dateCreated), SortBy.dateUpdated => a.lastUpdated.compareTo(b.lastUpdated), }; if (result != 0) return direction * result; diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index a135128b..21345b61 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -16,8 +16,12 @@ import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; +// Tabs sit apart so a selected and a hovered background never touch. +const double _tabGap = 4; +// Matches the ghost button's own radius so the rim hugs its hover shape. +const double _tabRadius = 6; // Action menus hug their labels; the account menu keeps room for email text. -const double _sortMenuWidth = 132; +const double _sortMenuWidth = 168; const double _newMenuWidth = 140; const double _accountMenuWidth = 200; const double _menuItemHorizontalPadding = 8; @@ -77,7 +81,7 @@ class _LibraryTitleStripState extends ConsumerState { return AppWindowStrip( child: Row( children: [ - const WindowsIcarusWordmark(), + const IcarusWordmark(), const SizedBox(width: 6), _TabButton( key: const ValueKey('library-tab-library'), @@ -87,6 +91,7 @@ class _LibraryTitleStripState extends ConsumerState { selected: tab == LibraryTab.library, onTap: navigation.showLibrary, ), + const SizedBox(width: _tabGap), _TabButton( key: const ValueKey('library-tab-shared'), icon: LucideIcons.users, @@ -100,6 +105,7 @@ class _LibraryTitleStripState extends ConsumerState { } }, ), + const SizedBox(width: _tabGap), _TabButton( key: const ValueKey('library-tab-community'), icon: LucideIcons.globe, @@ -173,7 +179,7 @@ class _LibraryTitleStripState extends ConsumerState { for (final value in SortBy.values) _MenuItem( menu: _sortController, - icon: value == filter.sortBy ? Icons.check : null, + icon: value == filter.sortBy ? LucideIcons.check : null, label: StrategyFilterProvider.sortByLabels[value]!, onPressed: () { ref.read(strategyFilterProvider.notifier).setSortBy(value); @@ -233,7 +239,7 @@ class _LibraryTitleStripState extends ConsumerState { _MenuItem( menu: _newController, key: const ValueKey('library-new-strategy'), - icon: Icons.note_add_outlined, + icon: LucideIcons.filePlus, label: 'New Strategy', onPressed: widget.onCreateStrategy, ), @@ -248,19 +254,19 @@ class _LibraryTitleStripState extends ConsumerState { const _MenuDivider(), _MenuItem( menu: _newController, - icon: Icons.file_download_outlined, + icon: LucideIcons.fileDown, label: 'Import .ica', onPressed: widget.onImportIca, ), _MenuItem( menu: _newController, - icon: Icons.archive_outlined, + icon: LucideIcons.archiveRestore, label: 'Import Backup', onPressed: widget.onImportBackup, ), _MenuItem( menu: _newController, - icon: Icons.backup_outlined, + icon: LucideIcons.archive, label: 'Export Library', onPressed: widget.onExportLibrary, ), @@ -273,8 +279,8 @@ class _LibraryTitleStripState extends ConsumerState { height: _controlHeight, padding: const EdgeInsets.only(left: 8, right: 6), onPressed: _newController.toggle, - leading: const Icon(Icons.add, size: 16), - trailing: const Icon(Icons.keyboard_arrow_down, size: 16), + leading: const Icon(LucideIcons.plus, size: 16), + trailing: const Icon(LucideIcons.chevronDown, size: 14), child: const Text('New'), ), ); @@ -370,7 +376,7 @@ class _LibraryTitleStripState extends ConsumerState { radius: 12, backgroundColor: Settings.tacticalVioletTheme.secondary, avatarUrl: auth.avatarUrl, - fallback: const Icon(Icons.person, size: 14), + fallback: const Icon(LucideIcons.user, size: 14), ), ), ), @@ -411,17 +417,24 @@ class _TabButton extends StatelessWidget { onTap: onTap, child: Opacity( opacity: dimmed ? 0.45 : 1, - child: ShadButton.ghost( - height: _controlHeight, - padding: const EdgeInsets.symmetric(horizontal: 10), - backgroundColor: selected ? theme.secondary : null, - foregroundColor: foreground, - hoverForegroundColor: theme.foreground, - onPressed: onTap, - leading: Icon(icon, size: 15), - child: Text( - label, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + child: DecoratedBox( + decoration: selected + ? Settings.raisedSurface(_tabRadius) + : const BoxDecoration(), + child: ShadButton.ghost( + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + gap: 6, + backgroundColor: selected ? Colors.transparent : null, + hoverBackgroundColor: selected ? Colors.transparent : null, + foregroundColor: foreground, + hoverForegroundColor: theme.foreground, + onPressed: onTap, + leading: Icon(icon, size: 15), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), ), ), ), @@ -459,7 +472,15 @@ class _MenuItem extends StatelessWidget { }, leading: SizedBox( width: _menuIconWidth, - child: icon == null ? null : Icon(icon, size: 16), + child: icon == null + ? null + : Icon( + icon, + size: 16, + color: icon == LucideIcons.check + ? Settings.tacticalVioletTheme.primary + : Settings.tacticalVioletTheme.mutedForeground, + ), ), child: Flexible( child: Text( diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 7f6c4e1c..a37da055 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -122,7 +122,7 @@ class _ImageCarouselState extends ConsumerState if (!file.existsSync() && (remoteUrl == null || remoteUrl.isEmpty)) { return const Center( - child: Icon(Icons.broken_image, color: Colors.white), + child: Icon(LucideIcons.imageOff, color: Colors.white), ); } diff --git a/lib/widgets/line_up_placer.dart b/lib/widgets/line_up_placer.dart index 561332f8..51c2aceb 100644 --- a/lib/widgets/line_up_placer.dart +++ b/lib/widgets/line_up_placer.dart @@ -53,14 +53,7 @@ class _LineupPositionWidgetState extends ConsumerState { horizontal: 12, vertical: 8, ), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - boxShadow: const [Settings.cardForegroundBackdrop], - ), + decoration: Settings.raisedPrimary(8), child: Text( "Drag an agent to the map to start placing", style: ShadTheme.of( diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index ca20e482..34bded59 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/map_tile.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class MapSelector extends ConsumerStatefulWidget { const MapSelector({super.key}); @@ -15,12 +16,19 @@ class MapSelector extends ConsumerStatefulWidget { } class _MapSelectorState extends ConsumerState { - static const double _cardWidth = 262; static const double _cardHeight = 65; - static const double _outerRadius = 10; + static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; + static const double _borderWidth = 1; static const double _sideToggleWidth = 66; + // Sized from the contents so the gap on the right of the side toggle equals + // the gap on the left of the map tile. + static const double _cardWidth = 2 * _borderWidth + + 2 * _innerGap + + MapTile.width + + _innerGap + + _sideToggleWidth; final OverlayPortalController _controller = OverlayPortalController(); final _link = LayerLink(); @@ -78,7 +86,7 @@ class _MapSelectorState extends ConsumerState { borderRadius: const BorderRadius.all(Radius.circular(_outerRadius)), border: Border.all( color: Settings.tacticalVioletTheme.border, - width: 2, + width: _borderWidth, ), ), width: _cardWidth, @@ -222,7 +230,7 @@ class _MapSelectorState extends ConsumerState { Icon( (ref.watch(mapProvider).isAttack) ? CustomIcons.sword - : Icons.shield, + : LucideIcons.shield, size: 20, color: (ref.watch(mapProvider).isAttack) ? Colors.redAccent diff --git a/lib/widgets/map_tile.dart b/lib/widgets/map_tile.dart index fc1d9e83..eaf58c5d 100644 --- a/lib/widgets/map_tile.dart +++ b/lib/widgets/map_tile.dart @@ -18,6 +18,10 @@ class MapTile extends ConsumerStatefulWidget { final bool isActive; final double borderRadius; + /// The tile's fixed footprint; the map card sizes itself from this. + static const double width = 180; + static const double height = 65; + @override ConsumerState createState() => _MapTileState(); } @@ -38,8 +42,8 @@ class _MapTileState extends ConsumerState { mouseCursor: SystemMouseCursors.click, onTap: widget.onTap, child: SizedBox( - width: 180, - height: 65, + width: MapTile.width, + height: MapTile.height, child: Stack( children: [ Positioned.fill( diff --git a/lib/widgets/numeric_drag_input.dart b/lib/widgets/numeric_drag_input.dart index 43aa1de7..1ec47759 100644 --- a/lib/widgets/numeric_drag_input.dart +++ b/lib/widgets/numeric_drag_input.dart @@ -321,7 +321,7 @@ class _NumericDragInputState extends State { child: Padding( padding: trailingPadding, child: Icon( - Icons.drag_indicator, + LucideIcons.gripVertical, size: widget.dragIconSize, color: _isDragging ? colorScheme.primary diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index d595a42a..e834fe5d 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -10,7 +10,6 @@ import 'package:icarus/providers/strategy_page_session_provider.dart' import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; @@ -237,7 +236,7 @@ class _PagesBarState extends ConsumerState { ), ShadButton( onPressed: () => Navigator.of(ctx).pop(controller.text.trim()), - leading: const Icon(Icons.text_fields), + leading: const Icon(LucideIcons.type, size: 16), child: const Text("Rename"), ), ], @@ -495,7 +494,7 @@ class _CollapsedPill extends StatelessWidget { children: [ const SizedBox(width: _pagesBarControlInset), _SquareIconButton( - icon: Icons.add, + icon: LucideIcons.plus, onTap: onAdd, tooltip: "Add page", color: Settings.tacticalVioletTheme.primary, @@ -508,7 +507,7 @@ class _CollapsedPill extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontSize: 14), ), ), @@ -518,7 +517,8 @@ class _CollapsedPill extends StatelessWidget { padding: EdgeInsets.zero, foregroundColor: Colors.white, onPressed: onToggle, - icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white), + icon: const Icon(LucideIcons.chevronDown, + size: 20, color: Colors.white), decoration: ShadDecoration( border: ShadBorder( radius: BorderRadius.circular(_pagesBarInnerButtonRadius), @@ -714,7 +714,7 @@ class _ExpandedPanel extends ConsumerWidget { children: [ const SizedBox(width: _pagesBarControlInset), _SquareIconButton( - icon: Icons.add, + icon: LucideIcons.plus, onTap: canAddPage ? onAdd : null, tooltip: "Add page", color: Settings.tacticalVioletTheme.primary, @@ -727,8 +727,8 @@ class _ExpandedPanel extends ConsumerWidget { padding: EdgeInsets.zero, foregroundColor: Colors.white, onPressed: onCollapse, - icon: - const Icon(Icons.keyboard_arrow_up, color: Colors.white), + icon: const Icon(LucideIcons.chevronUp, + size: 20, color: Colors.white), decoration: ShadDecoration( border: ShadBorder( radius: BorderRadius.circular( @@ -903,9 +903,7 @@ class _PageRowState extends State<_PageRow> { final fillProgress = widget.transitionProgress?.clamp(0.0, 1.0); final showActions = _hovered || widget.active || widget.transitionProgress != null; - final bg = widget.active && fillProgress == null - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.card; + final isRaised = widget.active && fillProgress == null; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), @@ -920,34 +918,48 @@ class _PageRowState extends State<_PageRow> { onTap: () => widget.onSelect(widget.page.id), child: ClipRRect( borderRadius: BorderRadius.circular(_PageRow._rowRadius), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_PageRow._rowRadius), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Settings.tacticalVioletTheme.card - .withValues(alpha: 0.2), - blurRadius: 12, - offset: const Offset(0, 4)) - ], - color: bg, - ), + // Every layer is a Positioned.fill over one fixed-size box, so + // the surface, the transition sweep, and the content share the + // same rect in every state. (A Container would inset its child + // by the resting border's width and shift everything 1px when + // the row turns active, which has no border.) + child: SizedBox( height: _PageRow._rowHeight, child: Stack( children: [ + Positioned.fill( + child: DecoratedBox( + decoration: isRaised + ? Settings.raisedPrimary(_PageRow._rowRadius) + : BoxDecoration( + borderRadius: + BorderRadius.circular(_PageRow._rowRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Settings.tacticalVioletTheme.card + .withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + color: Settings.tacticalVioletTheme.card, + ), + ), + ), if (fillProgress != null) Positioned.fill( child: FractionallySizedBox( widthFactor: fillProgress, alignment: Alignment.centerLeft, + // The same surface as the active row, so the + // sweep's corners match at every width and it + // lands exactly on the active state. child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - ), + decoration: + Settings.raisedPrimary(_PageRow._rowRadius), ), ), ), @@ -962,9 +974,7 @@ class _PageRowState extends State<_PageRow> { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: widget.active - ? FontWeight.w600 - : FontWeight.w500, + fontWeight: FontWeight.w500, fontSize: 14, ), ), @@ -1090,7 +1100,7 @@ class _SquareIconButton extends StatelessWidget { backgroundColor: color, hoverBackgroundColor: color, foregroundColor: Colors.white, - icon: Icon(icon), + icon: Icon(icon, size: 20), width: _pagesBarControlSize, height: _pagesBarControlSize, padding: EdgeInsets.zero, diff --git a/lib/widgets/role_badge.dart b/lib/widgets/role_badge.dart index 682b90a3..b2368d4a 100644 --- a/lib/widgets/role_badge.dart +++ b/lib/widgets/role_badge.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; -/// Small uppercase micro badges used to mark cloud ownership and share role. -/// -/// Styling follows the Icarus design system: 10px, w600, 0.5 letter spacing, -/// uppercase text on a tinted pill. Colors come only from the tactical theme -/// tokens (no hardcoded Material colors). +/// What the cloud says about a strategy's place in the library: yours, shared +/// with edit access, or shared read-only. enum CloudBadgeKind { owned, editor, viewer } CloudBadgeKind? cloudBadgeKindForRole(String? role) { @@ -26,87 +24,90 @@ CloudBadgeKind? cloudBadgeKindForRole(String? role) { } } -class CloudRoleBadge extends StatelessWidget { - const CloudRoleBadge({super.key, required this.kind}); +/// The pill drawn over a strategy thumbnail to say where it lives or how it +/// was shared. Owned cloud strategies are the ordinary case in a signed-in +/// library, so they carry no badge; only the exceptions get one. +class StrategyBadge extends StatelessWidget { + const StrategyBadge({ + super.key, + required this.icon, + required this.label, + required this.tooltip, + }); - final CloudBadgeKind kind; + final IconData icon; + final String label; + final String tooltip; @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - - switch (kind) { - case CloudBadgeKind.owned: - // Owned cloud strategies: a muted cloud glyph pill, deliberately quiet. - return _BadgePill( - background: theme.muted, - border: theme.border, - child: Icon( - Icons.cloud_outlined, - size: 12, - color: theme.mutedForeground, - ), - ); - case CloudBadgeKind.editor: - // Shared with edit access: violet tint to echo the "action" accent. - return _BadgePill( - background: theme.primary.withValues(alpha: 0.16), - border: theme.primary.withValues(alpha: 0.32), - child: _BadgeLabel(text: 'EDIT', color: theme.primary), - ); - case CloudBadgeKind.viewer: - // Shared read-only: muted, no command color. - return _BadgePill( - background: theme.muted, - border: theme.border, - child: _BadgeLabel(text: 'VIEW', color: theme.mutedForeground), - ); - } + return Tooltip( + message: tooltip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.background.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: theme.foreground), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.foreground, + ), + ), + ], + ), + ), + ); } } -class _BadgePill extends StatelessWidget { - const _BadgePill({ - required this.background, - required this.border, - required this.child, - }); - - final Color background; - final Color border; - final Widget child; +/// "On this device": the strategy exists only in the local library. +class DeviceOnlyBadge extends StatelessWidget { + const DeviceOnlyBadge({super.key}); @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: border), - ), - child: child, + return const StrategyBadge( + icon: LucideIcons.monitor, + label: 'On this device', + tooltip: 'Saved only on this computer', ); } } -class _BadgeLabel extends StatelessWidget { - const _BadgeLabel({required this.text, required this.color}); +/// The share role of a strategy someone else owns. Null for owned strategies. +class CloudRoleBadge extends StatelessWidget { + const CloudRoleBadge({super.key, required this.kind}); - final String text; - final Color color; + final CloudBadgeKind kind; @override Widget build(BuildContext context) { - return Text( - text, - style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - height: 1.2, - ), - ); + switch (kind) { + case CloudBadgeKind.owned: + return const SizedBox.shrink(); + case CloudBadgeKind.editor: + return const StrategyBadge( + icon: LucideIcons.users, + label: 'Shared · Can edit', + tooltip: 'Shared with you. You can edit it.', + ); + case CloudBadgeKind.viewer: + return const StrategyBadge( + icon: LucideIcons.users, + label: 'Shared · View only', + tooltip: 'Shared with you. Ask the owner for edit access.', + ); + } } } diff --git a/lib/widgets/save_and_load_button.dart b/lib/widgets/save_and_load_button.dart deleted file mode 100644 index 56d00c40..00000000 --- a/lib/widgets/save_and_load_button.dart +++ /dev/null @@ -1,289 +0,0 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/hive_boxes.dart'; -import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; -import 'package:icarus/providers/drawing_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/screenshot_provider.dart'; -import 'package:icarus/providers/strategy_page_session_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/services/cloud_strategy_export.dart'; -import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_models.dart'; -import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/screenshot/offscreen_capture.dart'; -import 'package:icarus/screenshot/screenshot_view.dart'; -import 'package:icarus/widgets/dialogs/export_video_dialog.dart'; -import 'package:icarus/widgets/settings_tab.dart'; -import 'package:icarus/widgets/cloud_sync_status_chip.dart'; -import 'package:icarus/widgets/strategy_save_icon_button.dart'; -import 'package:screenshot/screenshot.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -class SaveAndLoadButton extends ConsumerStatefulWidget { - const SaveAndLoadButton({super.key}); - - @override - ConsumerState createState() => - _SaveAndLoadButtonState(); -} - -class _SaveAndLoadButtonState extends ConsumerState { - bool _isLoading = false; - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - ShadTooltip( - builder: (context) => const Text("Settings"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - showShadDialog( - context: context, - builder: (context) => const SettingsTab(), - ); - }, - icon: const Icon(Icons.settings), - ), - ), - const AutoSaveButton(), - const CloudSyncStatusChip(), - ShadTooltip( - builder: (context) => const Text("Export"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the desktop app.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } - - final strategy = ref.read(strategyProvider); - final strategyId = strategy.strategyId; - if (strategyId == null || strategy.source == null) { - throw StateError('No strategy is open for export.'); - } - - final exporter = StrategyImportExportService(ref); - switch (strategy.source!) { - case StrategySource.cloud: - await runCloudStrategyExport(ref, strategyId); - case StrategySource.local: - await exporter.exportFile(strategyId); - } - }, - icon: const Icon(Icons.file_upload), - ), - ), - ShadTooltip( - builder: (context) => const Text("Export Video"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } - showShadDialog( - context: context, - builder: (context) => const ExportVideoDialog(), - ); - }, - icon: const Icon(Icons.movie_outlined), - ), - ), - ShadTooltip( - builder: (context) => const Text("Screenshot"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the desktop app.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } - if (_isLoading) return; - setState(() { - _isLoading = true; - }); - CoordinateSystem.instance.setIsScreenshot(true); - - final String id = ref.read(strategyProvider).strategyId!; - - await ref.read(strategyProvider.notifier).forceSaveNow(id); - - final newStrat = - Hive.box(HiveBoxNames.strategiesBox) - .values - .where((StrategyData strategy) { - return strategy.id == id; - }).firstOrNull; - - if (newStrat == null) { - return; - } - final newController = ScreenshotController(); - final mapState = ref.read(mapProvider); - final currentPageID = - ref.read(strategyPageSessionProvider).activePageId; - - if (currentPageID == null) return; - - final activePage = newStrat.pages.firstWhere( - (p) => p.id == currentPageID, - orElse: () => newStrat.pages.first, - ); - final screenshotContainer = ProviderContainer(); - - try { - final screenshotView = ScreenshotView( - isAttack: activePage.isAttack, - mapValue: newStrat.mapData, - showSpawnBarrier: mapState.showSpawnBarrier, - showRegionNames: mapState.showRegionNames, - showUltOrbs: mapState.showUltOrbs, - agents: activePage.agentData, - abilities: activePage.abilityData, - text: activePage.textData, - images: activePage.imageData, - drawings: activePage.drawingData, - utilities: activePage.utilityData, - strategySettings: activePage.settings, - strategyState: ref.read(strategyProvider), - pageName: activePage.name, - lineUpGroups: activePage.lineUpGroups, - themeProfileId: newStrat.themeProfileId, - themeOverridePalette: newStrat.themeOverridePalette, - ); - screenshotView.hydrateProviders(screenshotContainer); - final image = await newController.captureFromWidget( - targetSize: CoordinateSystem.screenShotSize, - wrapForOffscreenCapture( - screenshotView, - container: screenshotContainer, - ), - ); - setState(() { - _isLoading = false; - }); - String? outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: - "${ref.read(strategyProvider).strategyName ?? "new image"}.png", - allowedExtensions: ['png'], - ); - if (outputFile != null) { - final file = File(outputFile); - await file.writeAsBytes(image); - } - } catch (_) { - } finally { - screenshotContainer.dispose(); - ref.read(screenshotProvider.notifier).setIsScreenShot(false); - CoordinateSystem.instance.setIsScreenshot(false); - ref - .read(drawingProvider.notifier) - .rebuildAllPaths(CoordinateSystem.instance); - } - // CoordinateSystem.instance.setIsScreenshot(false); - }, - icon: _isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator( - color: Colors.white, - ), - ) - : const Icon(Icons.camera_alt_outlined), - ), - ), - if (_isViewOnly()) ...[ - const SizedBox(width: 4), - const _ViewOnlyChip(), - ], - ], - ), - ); - } - - bool _isViewOnly() { - final source = ref.watch(strategyProvider.select((value) => value.source)); - if (source != StrategySource.cloud) { - return false; - } - // Read the cached role rather than the raw snapshot so the chip does not - // flicker off while the snapshot is reloading or transiently errored; it - // is absent only before the role has ever been known. - final role = ref.watch(lastKnownCloudRoleProvider); - return role == 'viewer'; - } -} - -/// Non-interactive chip shown in the editor top strip when the open cloud -/// strategy is shared with view-only access. -class _ViewOnlyChip extends StatelessWidget { - const _ViewOnlyChip(); - - @override - Widget build(BuildContext context) { - const theme = Settings.tacticalVioletTheme; - - return ShadTooltip( - builder: (context) => const Text( - 'You have view access. Ask the owner for edit access to make changes.', - ), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: theme.muted, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.visibility_outlined, - size: 14, - color: theme.mutedForeground, - ), - const SizedBox(width: 6), - Text( - 'View only', - style: TextStyle( - color: theme.mutedForeground, - fontSize: 12, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - height: 1.2, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/selectable_icon_button.dart b/lib/widgets/selectable_icon_button.dart index b76ad8c3..e320018b 100644 --- a/lib/widgets/selectable_icon_button.dart +++ b/lib/widgets/selectable_icon_button.dart @@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +// The secondary icon button's own corner radius (the theme default). +const double _radius = 6; + class SelectableIconButton extends ConsumerWidget { const SelectableIconButton({ super.key, @@ -24,17 +27,24 @@ class SelectableIconButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasShortcutLabel = shortcutLabel != null && shortcutLabel!.isNotEmpty; + // A checked tool is a raised command surface. A caller-supplied color + // (the delete tools' red) stays a flat fill. + final raised = isSelected && hoverBackgroundColor == null; + final flatColor = + isSelected ? hoverBackgroundColor ?? Colors.transparent : null; Widget button = ShadIconButton.secondary( padding: EdgeInsets.zero, icon: icon, - backgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, - hoverBackgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, + backgroundColor: flatColor, + hoverBackgroundColor: flatColor, onPressed: onPressed, ); + if (raised) { + button = DecoratedBox( + decoration: Settings.raisedPrimary(_radius), + child: button, + ); + } // No tooltip text means no ShadTooltip wrapper; an empty tooltip bubble // would still pop up on hover otherwise. diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index dd36c40c..1fe9a71e 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -13,7 +13,6 @@ import 'package:icarus/providers/marker_sizes_sync.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/analytics_service.dart'; import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/account_avatar.dart'; @@ -205,7 +204,7 @@ class _StrategySettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsSliderTile( - icon: Icons.person_pin_circle_outlined, + icon: LucideIcons.personStanding, iconColor: Settings.settingsAgentAccent, title: "Agent markers", description: @@ -233,7 +232,7 @@ class _StrategySettingsSections extends ConsumerWidget { }, ), _SettingsSliderTile( - icon: Icons.auto_awesome_outlined, + icon: LucideIcons.sparkles, iconColor: Settings.settingsAbilityAccent, title: "Ability markers", description: @@ -261,7 +260,7 @@ class _StrategySettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.contrast_outlined, + icon: LucideIcons.contrast, iconColor: Settings.settingsNeutralAccent, title: "Neutral team marker colors", description: @@ -323,7 +322,7 @@ class _GlobalSettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsSliderTile( - icon: Icons.person_pin_circle_outlined, + icon: LucideIcons.personStanding, iconColor: Settings.settingsAgentAccent, title: "Default agent markers", description: @@ -340,7 +339,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsSliderTile( - icon: Icons.auto_awesome_outlined, + icon: LucideIcons.sparkles, iconColor: Settings.settingsAbilityAccent, title: "Default ability markers", description: @@ -357,7 +356,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.contrast_outlined, + icon: LucideIcons.contrast, iconColor: Settings.settingsNeutralAccent, title: "Neutral marker colors by default", description: @@ -382,7 +381,7 @@ class _GlobalSettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsToggleTile( - icon: Icons.save_outlined, + icon: LucideIcons.save, iconColor: Settings.settingsPersistenceAccent, title: "Autosave", description: @@ -398,7 +397,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.sports_esports_outlined, + icon: LucideIcons.gamepad2, iconColor: Settings.settingsDiscordAccent, title: "Discord Rich Presence", description: @@ -420,7 +419,7 @@ class _GlobalSettingsSections extends ConsumerWidget { child: Column( children: [ _SettingsToggleTile( - icon: Icons.grid_on_rounded, + icon: LucideIcons.grid3x3, iconColor: Settings.settingsMapAccent, title: "Spawn barriers", description: @@ -431,7 +430,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.location_on_outlined, + icon: LucideIcons.mapPin, iconColor: Settings.settingsMapAccent, title: "Region names", description: "Show map callout names directly on the canvas.", @@ -441,7 +440,7 @@ class _GlobalSettingsSections extends ConsumerWidget { }, ), _SettingsToggleTile( - icon: Icons.radio_button_checked_outlined, + icon: LucideIcons.circleDot, iconColor: Settings.settingsMapAccent, title: "Ultimate orbs", description: "Display orb pickup markers on supported maps.", @@ -458,7 +457,7 @@ class _GlobalSettingsSections extends ConsumerWidget { key: sectionKeys[_SettingsSection.globalPrivacy], title: "Privacy", child: _SettingsToggleTile( - icon: Icons.analytics_outlined, + icon: LucideIcons.chartColumn, iconColor: Settings.settingsPersistenceAccent, title: "Anonymous analytics", description: @@ -530,7 +529,7 @@ class _ShortcutSettingsSectionState const SizedBox(width: 12), ShadButton.secondary( size: ShadButtonSize.sm, - leading: const Icon(Icons.restart_alt_outlined, size: 15), + leading: const Icon(LucideIcons.rotateCcw, size: 15), onPressed: customBindings.isEmpty ? null : () { @@ -650,7 +649,7 @@ class _ShortcutSearchField extends StatelessWidget { fontSize: 13, ), prefixIcon: Icon( - Icons.search, + LucideIcons.search, size: 17, color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -770,7 +769,7 @@ class _ShortcutBindingRow extends StatelessWidget { ShadTooltip( builder: (_) => const Text("Reset to default"), child: ShadIconButton.ghost( - icon: const Icon(Icons.undo_outlined, size: 15), + icon: const Icon(LucideIcons.undo2, size: 15), onPressed: onReset, ), ) @@ -959,7 +958,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> child: Row( children: [ Icon( - Icons.keyboard_alt_outlined, + LucideIcons.keyboard, size: 17, color: hasDuplicate ? Settings.tacticalVioletTheme.destructive @@ -1058,7 +1057,7 @@ class AccountSettingsSection extends ConsumerWidget { child: Row( children: [ Icon( - Icons.cloud_off_outlined, + LucideIcons.cloudOff, size: 18, color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -1106,22 +1105,22 @@ class _SignedInAccountRow extends ConsumerWidget { switch (authState.convexAuthStatus) { ConvexAuthStatus.ready => ( 'Cloud sync active', - Icons.cloud_done_outlined, + LucideIcons.cloudCheck, theme.mutedForeground, ), ConvexAuthStatus.configuring => ( 'Connecting to cloud…', - Icons.cloud_sync_outlined, + LucideIcons.refreshCw, theme.mutedForeground, ), ConvexAuthStatus.incident => ( 'Cloud connection needs attention', - Icons.error_outline, + LucideIcons.circleAlert, theme.destructive, ), ConvexAuthStatus.signedOut => ( 'Cloud sync inactive', - Icons.cloud_off_outlined, + LucideIcons.cloudOff, theme.mutedForeground, ), }; @@ -1240,13 +1239,13 @@ class _SettingsNavigationRail extends StatelessWidget { const _SettingsNavHeader(label: "Current strategy"), const SizedBox(height: 4), _SettingsNavItem( - icon: Icons.tune_outlined, + icon: LucideIcons.slidersHorizontal, label: "Object styling", isSelected: selectedSection == _SettingsSection.strategyObjects, onTap: () => onSectionSelected(_SettingsSection.strategyObjects), ), _SettingsNavItem( - icon: Icons.palette_outlined, + icon: LucideIcons.palette, label: "Map theme", isSelected: selectedSection == _SettingsSection.strategyMapTheme, onTap: () => onSectionSelected(_SettingsSection.strategyMapTheme), @@ -1255,38 +1254,38 @@ class _SettingsNavigationRail extends StatelessWidget { const _SettingsNavHeader(label: "App-wide"), const SizedBox(height: 4), _SettingsNavItem( - icon: Icons.person_outline, + icon: LucideIcons.user, label: "Account", isSelected: selectedSection == _SettingsSection.globalAccount, onTap: () => onSectionSelected(_SettingsSection.globalAccount), ), _SettingsNavItem( - icon: Icons.auto_fix_high_outlined, + icon: LucideIcons.wandSparkles, label: "Defaults", isSelected: selectedSection == _SettingsSection.globalDefaults, onTap: () => onSectionSelected(_SettingsSection.globalDefaults), ), _SettingsNavItem( - icon: Icons.save_outlined, + icon: LucideIcons.save, label: "Autosave", isSelected: selectedSection == _SettingsSection.globalSaving, onTap: () => onSectionSelected(_SettingsSection.globalSaving), ), _SettingsNavItem( - icon: Icons.map_outlined, + icon: LucideIcons.map, label: "Map layers", isSelected: selectedSection == _SettingsSection.globalMapVisibility, onTap: () => onSectionSelected(_SettingsSection.globalMapVisibility), ), _SettingsNavItem( - icon: Icons.privacy_tip_outlined, + icon: LucideIcons.shield, label: "Privacy", isSelected: selectedSection == _SettingsSection.globalPrivacy, onTap: () => onSectionSelected(_SettingsSection.globalPrivacy), ), _SettingsNavItem( - icon: Icons.keyboard_alt_outlined, + icon: LucideIcons.keyboard, label: "Keybinds", isSelected: selectedSection == _SettingsSection.shortcuts, onTap: () => onSectionSelected(_SettingsSection.shortcuts), diff --git a/lib/widgets/sidebar_widgets/agent_dragable.dart b/lib/widgets/sidebar_widgets/agent_dragable.dart index 5e2861d4..e75ee4c2 100644 --- a/lib/widgets/sidebar_widgets/agent_dragable.dart +++ b/lib/widgets/sidebar_widgets/agent_dragable.dart @@ -144,8 +144,8 @@ class _AgentDragableState extends ConsumerState DateTime.now().isAfter(_starOffEnabledAt); final iconData = canShowStarOff ? LucideIcons.starOff - : (isFavorite ? Icons.star_rounded : LucideIcons.star); - final iconSize = iconData == Icons.star_rounded ? 18.5 : 16.0; + : (isFavorite ? LucideIcons.star600 : LucideIcons.star); + const iconSize = 16.0; final iconColor = isFavorite ? (canShowStarOff ? const Color(0xFFE53935) : const Color(0xFFFF9800)) : (_isStarHovered ? const Color(0xFFFF9800) : const Color(0xFF9AA0A6)); diff --git a/lib/widgets/sidebar_widgets/custom_shape_tools.dart b/lib/widgets/sidebar_widgets/custom_shape_tools.dart index abac7181..0d2f4a94 100644 --- a/lib/widgets/sidebar_widgets/custom_shape_tools.dart +++ b/lib/widgets/sidebar_widgets/custom_shape_tools.dart @@ -94,12 +94,12 @@ class _CustomShapeToolsState extends ConsumerState { spacing: 4, children: [ SelectableIconButton( - icon: const Icon(Icons.circle_outlined, size: 20), + icon: const Icon(LucideIcons.circle, size: 20), isSelected: _shape == _CustomShapeKind.circle, onPressed: () => setState(() => _shape = _CustomShapeKind.circle), ), SelectableIconButton( - icon: const Icon(Icons.crop_square, size: 20), + icon: const Icon(LucideIcons.square, size: 20), isSelected: _shape == _CustomShapeKind.rectangle, onPressed: () => setState(() => _shape = _CustomShapeKind.rectangle), @@ -178,8 +178,8 @@ class _CustomShapeToolsState extends ConsumerState { onChanged: (v) => setState(() => _opacityPercent = v.round()), min: 5, max: 80, - leading: Icon(Icons.opacity, - color: Settings.tacticalVioletTheme.mutedForeground), + leading: Icon(LucideIcons.droplet, + size: 16, color: Settings.tacticalVioletTheme.mutedForeground), // label: 'O', hintText: 'Enter Opacity', suffix: '%', diff --git a/lib/widgets/sidebar_widgets/delete_options.dart b/lib/widgets/sidebar_widgets/delete_options.dart index 779f5513..bc737b60 100644 --- a/lib/widgets/sidebar_widgets/delete_options.dart +++ b/lib/widgets/sidebar_widgets/delete_options.dart @@ -25,32 +25,32 @@ class DeleteOptions extends ConsumerWidget { static const List<_DeleteOptionData> _options = [ _DeleteOptionData( group: ActionGroup.agent, - icon: Icons.person, + icon: LucideIcons.user, label: 'Agents', ), _DeleteOptionData( group: ActionGroup.ability, - icon: Icons.bolt, + icon: LucideIcons.zap, label: 'Abilities', ), _DeleteOptionData( group: ActionGroup.drawing, - icon: Icons.draw, + icon: LucideIcons.pencil, label: 'Drawings', ), _DeleteOptionData( group: ActionGroup.text, - icon: Icons.text_fields, + icon: LucideIcons.type, label: 'Text', ), _DeleteOptionData( group: ActionGroup.image, - icon: Icons.image, + icon: LucideIcons.image, label: 'Images', ), _DeleteOptionData( group: ActionGroup.utility, - icon: Icons.crop_square, + icon: LucideIcons.square, label: 'Utilities', ), ]; diff --git a/lib/widgets/sidebar_widgets/drawing_tools.dart b/lib/widgets/sidebar_widgets/drawing_tools.dart index 9c047bb3..9b91203c 100644 --- a/lib/widgets/sidebar_widgets/drawing_tools.dart +++ b/lib/widgets/sidebar_widgets/drawing_tools.dart @@ -98,7 +98,7 @@ class DrawingTools extends ConsumerWidget { ), SelectableIconButton( icon: const Icon( - Icons.crop_square, + LucideIcons.square, size: 20, ), isSelected: penMode == PenMode.square, @@ -111,7 +111,7 @@ class DrawingTools extends ConsumerWidget { ), SelectableIconButton( icon: const Icon( - Icons.circle_outlined, + LucideIcons.circle, size: 20, ), isSelected: penMode == PenMode.ellipse, diff --git a/lib/widgets/sidebar_widgets/tool_grid.dart b/lib/widgets/sidebar_widgets/tool_grid.dart index aee8fc59..b89c997a 100644 --- a/lib/widgets/sidebar_widgets/tool_grid.dart +++ b/lib/widgets/sidebar_widgets/tool_grid.dart @@ -131,7 +131,7 @@ class ToolGrid extends ConsumerWidget { crossAxisSpacing: 5, children: [ SelectableIconButton( - icon: const Icon(Icons.draw), + icon: const Icon(LucideIcons.pencil, size: 22), tooltip: "Draw", shortcutLabel: shortcutLabel(IcarusShortcutAction.draw), onPressed: () { @@ -166,7 +166,7 @@ class ToolGrid extends ConsumerWidget { }, icon: const Icon( CustomIcons.eraser, - size: 20, + size: 22, ), isSelected: currentInteractionState == InteractionState.erasing, ), @@ -185,7 +185,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.textTools); } }, - icon: const Icon(Icons.text_fields), + icon: const Icon(LucideIcons.type, size: 22), isSelected: currentInteractionState == InteractionState.textTools, ), @@ -248,7 +248,7 @@ class ToolGrid extends ConsumerWidget { tagColorValue: imageResult.tagColorValue, ); }, - icon: const Icon(Icons.image_outlined), + icon: const Icon(LucideIcons.image, size: 22), ), ), SelectableIconButton( @@ -275,7 +275,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.lineUpPlacing); } }, - icon: const Icon(LucideIcons.bookOpen400), + icon: const Icon(LucideIcons.bookOpen400, size: 22), isSelected: ref.watch(interactionStateProvider) == InteractionState.lineUpPlacing, ), @@ -293,7 +293,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.visionCone); } }, - icon: const Icon(LucideIcons.eye, size: 20), + icon: const Icon(LucideIcons.eye, size: 22), isSelected: currentInteractionState == InteractionState.visionCone, ), @@ -311,7 +311,7 @@ class ToolGrid extends ConsumerWidget { .update(InteractionState.customShapes); } }, - icon: const Icon(Icons.crop_square, size: 20), + icon: const Icon(LucideIcons.square, size: 22), isSelected: currentInteractionState == InteractionState.customShapes, ), diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index b336f824..ba02d526 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -27,7 +27,13 @@ class StrategyQuickSwitcher extends ConsumerStatefulWidget { class _StrategyQuickSwitcherState extends ConsumerState { static const double _barWidth = 280; - static const double _barHeight = 40; + static const double _barHeight = 30; + static const double _barRadius = 8; + static const double _chevronWidth = 38; + // Menu geometry, matching the library strip's popovers. + static const double _menuRadius = 12; + static const double _menuInset = 6; + static const double _rowInset = 6; static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16); final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _layerLink = LayerLink(); @@ -297,73 +303,81 @@ class _StrategyQuickSwitcherState extends ConsumerState { left: left, top: top, width: _barWidth, - child: Material( - color: Colors.transparent, - child: Container( - constraints: BoxConstraints(maxHeight: maxHeight), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), + // A floating menu like the library strip's: popover + // grey, hairline border, panel radius, one shadow. The + // rows inside are flat. + child: Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.popover, + borderRadius: BorderRadius.circular(_menuRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, ), - child: recents.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Text( - 'No recent strategies', - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white70), + boxShadow: const [Settings.floatingMenuShadow], + ), + child: recents.isEmpty + ? Padding( + padding: const EdgeInsets.fromLTRB( + _menuInset + _rowInset, + _menuInset + 6, + _menuInset + _rowInset, + _menuInset + 6, + ), + child: Text( + 'No recent strategies', + style: TextStyle( + fontSize: 12, + color: Settings + .tacticalVioletTheme.mutedForeground, ), - ) - : ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.all(8), - itemCount: recents.length, - separatorBuilder: (_, __) => - const SizedBox(height: 8), - itemBuilder: (context, index) { - final strategy = recents[index]; - final attackLabel = _attackLabel(strategy); - final mapName = _mapName(strategy); - final thumbnail = - 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; - return _StrategyQuickSwitchItem( - strategyName: strategy.name, - mapName: mapName, - attackLabel: attackLabel, - attackColor: _attackColor(attackLabel), - lastEdited: _timeAgo(strategy.lastEdited), - thumbnailPath: thumbnail, - onTap: _isSwitching || _isEditingName - ? null - : () => _switchStrategy(strategy.id), - ); - }, ), - ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.all(_menuInset), + itemCount: recents.length, + separatorBuilder: (_, __) => + const SizedBox(height: 2), + itemBuilder: (context, index) { + final strategy = recents[index]; + final attackLabel = _attackLabel(strategy); + final mapName = _mapName(strategy); + final thumbnail = + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; + return _StrategyQuickSwitchItem( + strategyName: strategy.name, + mapName: mapName, + attackLabel: attackLabel, + attackColor: _attackColor(attackLabel), + lastEdited: _timeAgo(strategy.lastEdited), + thumbnailPath: thumbnail, + onTap: _isSwitching || _isEditingName + ? null + : () => _switchStrategy(strategy.id), + ); + }, + ), ), ), ], ); }, + // A two-segment control. The children are clipped to the + // bar's inner rounded rect, so each segment's hover fill runs + // edge to edge and the bar's own corners round it off. child: Container( key: const ValueKey('strategy-quick-switcher-control'), width: _barWidth, height: _barHeight, decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_barRadius), border: Border.all( color: Settings.tacticalVioletTheme.border, ), ), + clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( @@ -446,7 +460,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { currentStrategy.strategyName == null ? SystemMouseCursors.basic : SystemMouseCursors.click, - borderRadius: BorderRadius.circular(8), + hoverColor: + Settings.tacticalVioletTheme.accent, child: Center( child: Padding( padding: const EdgeInsets.symmetric( @@ -470,12 +485,17 @@ class _StrategyQuickSwitcherState extends ConsumerState { ), Container( width: 1, - height: 30, color: Settings.tacticalVioletTheme.border, ), SizedBox( - width: 38, + width: _chevronWidth, child: ShadIconButton.ghost( + width: _chevronWidth, + height: double.infinity, + padding: EdgeInsets.zero, + decoration: const ShadDecoration( + border: ShadBorder(radius: BorderRadius.zero), + ), onPressed: _isSwitching || _isEditingName ? null : () => _isOpen ? _closePortal() : _openPortal(), @@ -488,8 +508,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { ) : Icon( _isOpen - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, color: Colors.white, size: 18, ), @@ -531,106 +551,84 @@ class _StrategyQuickSwitchItem extends StatefulWidget { } class _StrategyQuickSwitchItemState extends State<_StrategyQuickSwitchItem> { - bool _isHovered = false; + static const double _rowHeight = 40; + static const double _thumbnail = 28; @override Widget build(BuildContext context) { - final isEnabled = widget.onTap != null; - final borderColor = _isHovered - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.border; - final backgroundColor = _isHovered - ? Settings.tacticalVioletTheme.card.withValues(alpha: 0.85) - : Settings.tacticalVioletTheme.card; - - return MouseRegion( - cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: borderColor), - boxShadow: const [Settings.cardForegroundBackdrop], + const theme = Settings.tacticalVioletTheme; + // A flat menu row: thumbnail, name over map, side over time. Hover is + // the ghost button's grey fill; nothing here is bordered or shadowed. + return ShadButton.ghost( + height: _rowHeight, + expands: true, + mainAxisAlignment: MainAxisAlignment.start, + padding: const EdgeInsets.symmetric( + horizontal: _StrategyQuickSwitcherState._rowInset, + ), + gap: 10, + onPressed: widget.onTap, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + widget.thumbnailPath, + width: _thumbnail, + height: _thumbnail, + fit: BoxFit.cover, ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - borderRadius: BorderRadius.circular(10), - mouseCursor: - isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - hoverColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.12), - splashColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.2), - child: Padding( - padding: const EdgeInsets.all(6), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - widget.thumbnailPath, - width: 46, - height: 46, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - OverflowTooltipText( - widget.strategyName, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - widget.mapName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white70, - ), - ), - ], - ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + OverflowTooltipText( + widget.strategyName, + style: TextStyle( + fontSize: 13, + height: 1.2, + color: theme.foreground, ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.attackLabel, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: widget.attackColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - widget.lastEdited, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white54, - ), - ), - ], + ), + Text( + widget.mapName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, ), - ], - ), + ), + ], ), ), - ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.attackLabel, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: widget.attackColor, + ), + ), + Text( + widget.lastEdited, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, + ), + ), + ], + ), + ], ), ); } diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 4d84cac0..2ae0644b 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -7,81 +7,99 @@ import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; -/// The save button that animates on auto-save pings. +/// Saves the open strategy immediately and tells the user what landed. +Future saveStrategyNow(BuildContext context, WidgetRef ref) async { + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return; + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + if (!context.mounted) return; + + final latestSaveState = ref.read(strategySaveStateProvider); + final hasIncompleteMediaSync = latestSaveState.hasPendingMediaSync || + latestSaveState.mediaSyncErrorCount > 0; + final toastMessage = hasIncompleteMediaSync + ? latestSaveState.mediaSyncErrorCount > 0 + ? 'Saved on this device. Media sync needs retry.' + : 'Saved on this device. Media still syncing.' + : 'Saved'; + + toastification.showCustom( + context: context, + autoCloseDuration: const Duration(seconds: 3), + alignment: Alignment.bottomCenter, + builder: (context, holder) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Text( + toastMessage, + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Settings.tacticalVioletTheme.foreground), + ), + ); + }, + ); +} + +/// The save button of a local strategy. Shows a spinner while an auto-save +/// runs and a check when it lands, then rests on the save glyph. class AutoSaveButton extends ConsumerStatefulWidget { - const AutoSaveButton({super.key}); + const AutoSaveButton({ + super.key, + this.style = kEditorToolbarButtonStyle, + }); + + final EditorToolbarButtonStyle style; @override ConsumerState createState() => _AutoSaveButtonState(); } -class _AutoSaveButtonState extends ConsumerState - with SingleTickerProviderStateMixin { - /// Listen to the autoSave ping counter. - // late final AutoDisposeProviderSubscription _sub; - - /// Drives continuous rotation in the loading phase. - late final AnimationController _rotationController; - - /// Our own internal phase. +class _AutoSaveButtonState extends ConsumerState { _Phase _phase = _Phase.idle; + Timer? _successTimer; + Timer? _idleTimer; + int _lastPing = 0; @override void initState() { super.initState(); _lastPing = ref.read(autoSaveProvider); - _rotationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - )..repeat(); // we'll stop when not loading } @override void dispose() { - _rotationController.dispose(); + _successTimer?.cancel(); + _idleTimer?.cancel(); super.dispose(); } void _startAutoSaveAnimation() { if (!mounted) return; - + _successTimer?.cancel(); + _idleTimer?.cancel(); setState(() => _phase = _Phase.loading); - _rotationController.repeat(); - - // After 3s, show check and snackbar - Timer(const Duration(seconds: 3), () { + _successTimer = Timer(const Duration(seconds: 3), () { if (!mounted) return; - _rotationController.stop(); setState(() => _phase = _Phase.success); - - // // show the snack bar here, outside build - // ScaffoldMessenger.of(context).showSnackBar( - // const SnackBar( - // content: Center( - // child: Text( - // "Auto‐save complete", - // style: TextStyle(color: Colors.white), - // ), - // ), - // duration: Duration(seconds: 2), - // backgroundColor: Settings.sideBarColor, - // behavior: SnackBarBehavior.floating, - // width: 200, - // ), - // ); - - // after 1s go back to idle - Timer(const Duration(seconds: 1), () { + _idleTimer = Timer(const Duration(seconds: 1), () { if (!mounted) return; setState(() => _phase = _Phase.idle); }); }); } - int _lastPing = 0; @override Widget build(BuildContext context) { final ping = ref.watch(autoSaveProvider); @@ -95,80 +113,39 @@ class _AutoSaveButtonState extends ConsumerState _lastPing = ping; _startAutoSaveAnimation(); } - Widget icon; - switch (_phase) { - case _Phase.idle: - _rotationController.stop(); - icon = const Icon(Icons.save); - break; - - case _Phase.loading: - _rotationController.stop(); - icon = const SizedBox( - width: 24, - height: 24, + + final size = widget.style.iconSize; + final Widget icon = switch (_phase) { + _Phase.idle => const Icon(LucideIcons.save200, key: ValueKey('idle')), + _Phase.loading => SizedBox( + key: const ValueKey('loading'), + width: size - 2, + height: size - 2, child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, + ), ), - ); - break; - - case _Phase.success: - _rotationController.stop(); - icon = const Icon(Icons.check, color: Colors.greenAccent); - - break; - } - - return ShadTooltip( - builder: (context) => Text(canEditPages ? "Save" : "View only"), - child: ShadIconButton.ghost( - foregroundColor: Colors.white, - icon: icon, - enabled: canEditPages, - onPressed: () async { - await ref - .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).strategyId!); - if (!context.mounted) return; - - final latestSaveState = ref.read(strategySaveStateProvider); - final hasIncompleteMediaSync = latestSaveState.hasPendingMediaSync || - latestSaveState.mediaSyncErrorCount > 0; - final toastMessage = hasIncompleteMediaSync - ? latestSaveState.mediaSyncErrorCount > 0 - ? 'Local save complete. Media sync needs retry.' - : 'Local save complete. Media still syncing.' - : 'Save Complete'; - - toastification.showCustom( - context: context, - autoCloseDuration: const Duration(seconds: 3), - alignment: Alignment.bottomCenter, - builder: (context, holder) { - return Container( - margin: const EdgeInsets.all(16), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), - child: Text( - toastMessage, - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white), - ), - ); - }, - ); - }, + ), + _Phase.success => const Icon( + LucideIcons.check200, + key: ValueKey('success'), + color: Settings.allyBGColor, + ), + }; + + return EditorToolbarButton( + key: const ValueKey('local-save-button'), + style: widget.style, + tooltip: canEditPages ? 'Save' : 'View only', + enabled: canEditPages, + onPressed: () => saveStrategyNow(context, ref), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: icon, ), ); } diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 7d7d143d..bc7fa069 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -9,7 +9,6 @@ import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; @@ -18,6 +17,7 @@ import 'package:icarus/widgets/dialogs/strategy/rename_strategy_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:icarus/widgets/role_badge.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -89,7 +89,7 @@ class StrategyTileActionsButton extends StatelessWidget { width: 28, height: 28, onPressed: onPressed, - icon: const Icon(Icons.more_vert_outlined), + icon: const Icon(LucideIcons.ellipsisVertical), ), ); } @@ -122,8 +122,10 @@ class StrategyTileMenuActionSemantics extends StatelessWidget { } } +const double _ringWidth = 2; + class _StrategyTileState extends ConsumerState { - Color _highlightColor = Settings.tacticalVioletTheme.border; + bool _isHovered = false; bool _isLoading = false; bool _menuButtonWasOpenOnPointerDown = false; DropInsertionSide? _pinnedDropSide; @@ -265,10 +267,8 @@ class _StrategyTileState extends ConsumerState { ), child: MouseRegion( cursor: SystemMouseCursors.click, - onEnter: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.ring), - onExit: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.border), + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), child: AbsorbPointer( absorbing: _isLoading, child: ShadContextMenuRegion( @@ -289,41 +289,50 @@ class _StrategyTileState extends ConsumerState { return Stack( clipBehavior: Clip.none, children: [ + // The ring is the outer box showing through a + // 2px inset: flat zinc at rest, and on hover the + // lit violet gradient, since a Border cannot + // take a gradient. AnimatedContainer( - duration: const Duration(milliseconds: 100), - decoration: BoxDecoration( - color: ShadTheme.of(context).colorScheme.card, - borderRadius: BorderRadius.circular( - strategyTileOuterRadius), - border: Border.all( - color: isPinDropTarget - ? Settings.tacticalVioletTheme.border - : _highlightColor, - width: 2, + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.border, + gradient: !isPinDropTarget && _isHovered + ? Settings.raisedPrimaryFill + : null, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius), ), - ), - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: StrategyTileThumbnail( - assetPath: viewData.thumbnailAsset, - borderRadius: strategyTileInnerRadius, - overlay: widget.showDeviceBadge - ? const DeviceOnlyBadge() - : null, - ), + padding: const EdgeInsets.all(_ringWidth), + child: Container( + decoration: BoxDecoration( + color: + ShadTheme.of(context).colorScheme.card, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius - _ringWidth), ), - const SizedBox(height: 10), - Expanded( - child: StrategyTileDetails( - data: viewData, - borderRadius: strategyTileInnerRadius, - ), + padding: const EdgeInsets.all(8 - _ringWidth), + child: Column( + children: [ + Expanded( + child: StrategyTileThumbnail( + assetPath: viewData.thumbnailAsset, + borderRadius: strategyTileInnerRadius, + overlay: widget.showDeviceBadge + ? const DeviceOnlyBadge() + : viewData.sharedBadge, + ), + ), + const SizedBox(height: 10), + Expanded( + child: StrategyTileDetails( + data: viewData, + borderRadius: strategyTileInnerRadius, + ), + ), + ], ), - ], - ), - ), + )), if (isPinned) Align( alignment: Alignment.topLeft, @@ -342,7 +351,7 @@ class _StrategyTileState extends ConsumerState { ), child: const Padding( padding: EdgeInsets.all(5), - child: Icon(Icons.push_pin, size: 15), + child: Icon(LucideIcons.pin, size: 15), ), ), ), @@ -363,8 +372,8 @@ class _StrategyTileState extends ConsumerState { width: 28, height: 28, onPressed: _handleMenuButtonPressed, - icon: - const Icon(Icons.more_vert_outlined), + icon: const Icon( + LucideIcons.ellipsisVertical), ), ), ), @@ -403,7 +412,7 @@ class _StrategyTileState extends ConsumerState { final isPinned = pinned.containsKey(id); return [ ShadContextMenuItem( - leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined), + leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), child: Text(isPinned ? 'Unpin' : 'Pin'), onPressed: () { _closeMenus(); @@ -412,7 +421,6 @@ class _StrategyTileState extends ConsumerState { ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), - child: const Text('Rename'), enabled: widget.canRename, onPressed: widget.canRename ? () { @@ -420,10 +428,10 @@ class _StrategyTileState extends ConsumerState { _showRenameDialog(); } : null, + child: const Text('Rename'), ), ShadContextMenuItem( leading: const Icon(LucideIcons.copy), - child: const Text('Duplicate'), enabled: widget.canDuplicate, onPressed: widget.canDuplicate ? () { @@ -431,6 +439,7 @@ class _StrategyTileState extends ConsumerState { _duplicateStrategy(); } : null, + child: const Text('Duplicate'), ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), @@ -450,8 +459,8 @@ class _StrategyTileState extends ConsumerState { }, ), ShadContextMenuItem( - leading: const Icon(LucideIcons.trash2, color: Colors.redAccent), - child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), + leading: Icon(LucideIcons.trash2, + color: Settings.tacticalVioletTheme.destructive), enabled: widget.canDelete, onPressed: widget.canDelete ? () { @@ -459,6 +468,8 @@ class _StrategyTileState extends ConsumerState { _showDeleteDialog(); } : null, + child: Text('Delete', + style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), ), ]; } diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index 4d934ba8..f15e19ef 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -68,10 +68,17 @@ class StrategyTileViewData { final String lastEditedLabel; final List agentTypes; - /// Non-null only for cloud strategies. Local tiles leave this null so they - /// render exactly as before. + /// Non-null only for cloud strategies. Owned strategies render no badge; + /// shared ones show their role over the thumbnail. final CloudBadgeKind? cloudBadge; + /// The pill drawn over the thumbnail, if this strategy needs one. + Widget? get sharedBadge { + final kind = cloudBadge; + if (kind == null || kind == CloudBadgeKind.owned) return null; + return CloudRoleBadge(kind: kind); + } + static String _mapName(MapValue? map) { final raw = map == null ? null : Maps.mapNames[map]; if (raw == null || raw.isEmpty) { @@ -187,45 +194,6 @@ class StrategyTileThumbnail extends StatelessWidget { } } -/// "On this device": the strategy exists only in the local library. -class DeviceOnlyBadge extends StatelessWidget { - const DeviceOnlyBadge({super.key}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Saved only on this computer', - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Settings.tacticalVioletTheme.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.monitor, - size: 12, - color: Settings.tacticalVioletTheme.foreground, - ), - const SizedBox(width: 5), - Text( - 'On this device', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Settings.tacticalVioletTheme.foreground, - ), - ), - ], - ), - ), - ); - } -} - class StrategyTileDetails extends StatelessWidget { const StrategyTileDetails({ super.key, @@ -271,10 +239,6 @@ class StrategyTileDetails extends StatelessWidget { ), ), ), - if (data.cloudBadge != null) ...[ - const SizedBox(width: 6), - CloudRoleBadge(kind: data.cloudBadge!), - ], ], ), const SizedBox(height: 5), @@ -418,7 +382,7 @@ class _MoreAgentsIndicator extends StatelessWidget { border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: const Icon( - Icons.more_horiz, + LucideIcons.ellipsis, color: Color.fromARGB(190, 210, 214, 219), size: 18, ), diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 70a79428..492ec138 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -42,11 +42,22 @@ class StrategyViewSkeleton extends StatelessWidget { isAttack: isAttack, ), ), - const Align( + Align( alignment: Alignment.topLeft, child: Padding( - padding: EdgeInsets.all(8), - child: _FloatingControlSkeleton(), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _MapSelectorSkeleton( + mapValue: resolvedMap, + isAttack: isAttack, + ), + const SizedBox(height: 8), + const _FloatingControlSkeleton(), + ], + ), ), ), const Align( @@ -157,68 +168,47 @@ class _SkeletonTopBar extends StatelessWidget { @override Widget build(BuildContext context) { final title = strategyName?.trim(); - return EditorWindowHeader(child: _buildBar(context, title)); - } - - Widget _buildBar(BuildContext context, String? title) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + return AppWindowStrip( + child: Stack( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, + const Row( children: [ - const _SkeletonBlock(width: 40, height: 40, radius: 8), - const SizedBox(width: 5), - _MapSelectorSkeleton(mapValue: mapValue, isAttack: isAttack), + SizedBox(width: 6), + _SkeletonBlock(width: 28, height: 28, radius: 8), + Spacer(), + _SkeletonBlock(width: 200, height: 28, radius: 8), + SizedBox(width: 10), ], ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + Center( + child: Container( + width: 280, + height: 30, + decoration: BoxDecoration( + color: _tone(Settings.tacticalVioletTheme.card, 0.95), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _tone(Settings.highlightColor, 0.82), + ), + ), child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: Container( - height: 40, - decoration: BoxDecoration( - color: _tone(Settings.tacticalVioletTheme.card, 0.95), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _tone(Settings.highlightColor, 0.82), + child: title == null || title.isEmpty + ? const _SkeletonBlock(width: 158, height: 12, radius: 5) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Colors.white70), + ), ), - ), - child: Center( - child: title == null || title.isEmpty - ? const _SkeletonBlock( - width: 158, - height: 12, - radius: 5, - ) - : Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of( - context, - ) - .textTheme - .small - .copyWith(color: Colors.white70), - ), - ), - ), - ), - ), ), ), ), - const _SkeletonBlock(width: 238, height: 40, radius: 8), ], ), ); @@ -228,7 +218,7 @@ class _SkeletonTopBar extends StatelessWidget { class _MapSelectorSkeleton extends StatelessWidget { const _MapSelectorSkeleton({required this.mapValue, required this.isAttack}); - static const double _outerRadius = 10; + static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; @@ -244,11 +234,8 @@ class _MapSelectorSkeleton extends StatelessWidget { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 2, - ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -275,7 +262,7 @@ class _MapSelectorSkeleton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - isAttack ? CustomIcons.sword : Icons.shield, + isAttack ? CustomIcons.sword : LucideIcons.shield, size: 20, color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -387,16 +374,25 @@ class _FloatingControlSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return const Row( - children: [ - _SkeletonBlock(width: 40, height: 40, radius: 8), - SizedBox(width: 8), - _SkeletonBlock(width: 40, height: 40, radius: 8), - SizedBox(width: 8), - _SkeletonBlock(width: 40, height: 40, radius: 8), - SizedBox(width: 8), - _SkeletonBlock(width: 40, height: 40, radius: 8), - ], + // Mirrors EditorToolbar: five 32px controls in one 12px card. + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _tone(Settings.tacticalVioletTheme.card, 0.9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _tone(Settings.highlightColor, 0.88)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SkeletonBlock(width: 32, height: 32, radius: 8), + _SkeletonBlock(width: 32, height: 32, radius: 8), + _SkeletonBlock(width: 32, height: 32, radius: 8), + _SkeletonBlock(width: 32, height: 32, radius: 8), + SizedBox(width: 9), + _SkeletonBlock(width: 32, height: 32, radius: 8), + ], + ), ); } } diff --git a/lib/widgets/vision_boundary_editor.dart b/lib/widgets/vision_boundary_editor.dart index 7cf3cd6b..5d021c7a 100644 --- a/lib/widgets/vision_boundary_editor.dart +++ b/lib/widgets/vision_boundary_editor.dart @@ -203,7 +203,7 @@ class VisionBoundaryEditorHud extends ConsumerWidget { ); } }, - icon: const Icon(Icons.polyline_outlined, size: 20), + icon: const Icon(LucideIcons.spline, size: 20), ), ); } @@ -252,7 +252,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.polyline_outlined, size: 18), + const Icon(LucideIcons.spline, size: 18), const SizedBox(width: 8), Expanded( child: Column( @@ -304,7 +304,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { ); } }, - icon: const Icon(Icons.close, size: 18), + icon: const Icon(LucideIcons.x, size: 18), ), ], ), @@ -373,14 +373,14 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { height: 32, enabled: notifier.canUndo, onPressed: notifier.canUndo ? notifier.undo : null, - icon: const Icon(Icons.undo, size: 18), + icon: const Icon(LucideIcons.undo, size: 18), ), ShadIconButton.ghost( width: 32, height: 32, enabled: notifier.canRedo, onPressed: notifier.canRedo ? notifier.redo : null, - icon: const Icon(Icons.redo, size: 18), + icon: const Icon(LucideIcons.redo, size: 18), ), ], ), @@ -406,19 +406,19 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { ), const SizedBox(width: 4), _NudgeButton( - icon: Icons.keyboard_arrow_left, + icon: LucideIcons.chevronLeft, onPressed: () => notifier.nudge(const Offset(-1, 0)), ), _NudgeButton( - icon: Icons.keyboard_arrow_up, + icon: LucideIcons.chevronUp, onPressed: () => notifier.nudge(const Offset(0, -1)), ), _NudgeButton( - icon: Icons.keyboard_arrow_down, + icon: LucideIcons.chevronDown, onPressed: () => notifier.nudge(const Offset(0, 1)), ), _NudgeButton( - icon: Icons.keyboard_arrow_right, + icon: LucideIcons.chevronRight, onPressed: () => notifier.nudge(const Offset(1, 0)), ), ], @@ -430,7 +430,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { enabled: editor.isDirty, onPressed: editor.isDirty ? notifier.discardChanges : null, - leading: const Icon(Icons.restart_alt, size: 16), + leading: const Icon(LucideIcons.rotateCcw, size: 16), child: const Text('Discard'), ), const SizedBox(width: 8), @@ -445,7 +445,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { Settings.tacticalVioletTheme.primary, ); }, - leading: const Icon(Icons.copy, size: 16), + leading: const Icon(LucideIcons.copy, size: 16), child: const Text('Copy JSON'), ), const Spacer(), @@ -491,7 +491,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget { strokeWidth: 2, ), ) - : const Icon(Icons.save_outlined, size: 16), + : const Icon(LucideIcons.save, size: 16), child: const Text('Save asset'), ), ], diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index dc9b3c7e..82e43ef7 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -1,38 +1,18 @@ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:icarus/const/settings.dart'; import 'package:window_manager/window_manager.dart'; -/// Height of the strip the app draws in place of the native title bar. -/// `macos/Runner/MainFlutterWindow.swift` centers the traffic lights on it. +/// Height of the strip the app draws in place of the native title bar. Every +/// screen uses it, so `macos/Runner/MainFlutterWindow.swift` can center the +/// traffic lights on it once and never move them. const double kWindowStripHeight = 40; -/// Height of the editor's header band: the 65px map card. -const double kEditorHeaderHeight = 65; - /// Room reserved on the left for the native macOS traffic lights. const double kMacTrafficLightInset = 78; -/// Talks to `MainFlutterWindow.swift`, which centers the traffic lights on -/// whatever band height the current screen reports. -const MethodChannel _chromeChannel = MethodChannel('icarus/window_chrome'); - -int _editorHeadersMounted = 0; - -Future _syncMacTitleStripHeight() async { - if (!_isMacOS) return; - final height = - _editorHeadersMounted > 0 ? kEditorHeaderHeight : kWindowStripHeight; - try { - await _chromeChannel.invokeMethod('setTitleStripHeight', height); - } on MissingPluginException { - // Running without the macOS runner (tests, other hosts). - } -} - bool get _isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; bool get _isWindows => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; @@ -60,71 +40,18 @@ class WindowDragArea extends StatelessWidget { } } -/// The editor's header: controls and the map card centered on one band, -/// with the traffic lights (macOS) or caption buttons (Windows, Linux) on -/// that same line. On macOS the window is told the band's height so the -/// lights move down to meet it, and back up when the editor closes. Windows -/// and Linux split the canvas gap evenly above and below the band. -class EditorWindowHeader extends StatefulWidget { - const EditorWindowHeader({super.key, required this.child}); - - final Widget child; - - @override - State createState() => _EditorWindowHeaderState(); -} - -class _EditorWindowHeaderState extends State { - @override - void initState() { - super.initState(); - _editorHeadersMounted++; - _syncMacTitleStripHeight(); - } - - @override - void dispose() { - // The skeleton and the real header swap within one frame, so count - // mounts instead of assuming this was the last one. - _editorHeadersMounted--; - _syncMacTitleStripHeight(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return WindowDragArea( - child: Padding( - padding: _drawsCaptionButtons - ? const EdgeInsets.symmetric(vertical: 5) - : const EdgeInsets.only(bottom: 10), - child: SizedBox( - height: kEditorHeaderHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const MacTrafficLightInset(), - Expanded(child: widget.child), - const WindowCaptionButtons(), - ], - ), - ), - ), - ); - } -} - -/// The compact Icarus lockup at the start of the Windows library strip. -class WindowsIcarusWordmark extends StatelessWidget { - const WindowsIcarusWordmark({super.key}); +/// The compact Icarus lockup at the start of the library strip. On macOS it +/// follows the traffic-light inset; on Windows and Linux it leads the strip. +class IcarusWordmark extends StatelessWidget { + const IcarusWordmark({super.key}); @override Widget build(BuildContext context) { - if (!_isWindows) { + if (kIsWeb) { return const SizedBox.shrink(); } return Padding( - padding: const EdgeInsets.only(left: 10, right: 4), + padding: const EdgeInsets.only(left: 10, right: 6), child: SvgPicture.asset( 'assets/brand/icarus-wordmark.svg', height: 14, @@ -265,6 +192,8 @@ class AppWindowStrip extends StatelessWidget { return Container( key: const ValueKey('app-window-strip'), height: kWindowStripHeight, + // The 1px seam is the edge of the window frame: everything under it + // is the bench, and the floating panels on it carry their own air. decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, border: Border( diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 245f4504..18da4aca 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -4,13 +4,11 @@ import FlutterMacOS /// Icarus draws its own title strip, so the window keeps only the native /// traffic lights and lets the Flutter view extend under the title bar. class MainFlutterWindow: NSWindow { - /// Height of the band the traffic lights are centered on. Starts at the - /// library strip's height (`kWindowStripHeight` in - /// lib/widgets/window_chrome.dart); screens with a taller header, like the - /// editor, update it over the `icarus/window_chrome` channel. - private var titleStripHeight: CGFloat = 40 + /// Height of the strip every screen draws in place of the title bar + /// (`kWindowStripHeight` in lib/widgets/window_chrome.dart). The traffic + /// lights are centered on it once and never move. + private let titleStripHeight: CGFloat = 40 private var layoutObservers: [NSObjectProtocol] = [] - private var chromeChannel: FlutterMethodChannel? override func awakeFromNib() { let flutterViewController = FlutterViewController() @@ -24,27 +22,6 @@ class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) - let channel = FlutterMethodChannel( - name: "icarus/window_chrome", - binaryMessenger: flutterViewController.engine.binaryMessenger - ) - channel.setMethodCallHandler { [weak self] call, result in - guard let self = self else { return } - switch call.method { - case "setTitleStripHeight": - guard let height = call.arguments as? Double else { - result(FlutterError(code: "bad-args", message: "height missing", details: nil)) - return - } - self.titleStripHeight = CGFloat(height) - self.centerTrafficLights() - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - chromeChannel = channel - super.awakeFromNib() observeTitleBarLayout() diff --git a/pubspec.lock b/pubspec.lock index 7eb0268c..bbf9557b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1592,7 +1592,7 @@ packages: source: hosted version: "3.1.0" win32: - dependency: transitive + dependency: "direct main" description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e diff --git a/pubspec.yaml b/pubspec.yaml index 5e648383..4be3a327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: custom_mouse_cursor: ^1.1.3 flutter_inappwebview: ^6.1.5 windows_single_instance: ^1.0.0 + win32: ^5.15.0 pasteboard: ^0.4.0 desktop_updater: ^1.4.0 cryptography_plus: ^2.7.1 diff --git a/test/ability_anchor_scaling_test.dart b/test/ability_anchor_scaling_test.dart index d365fef0..bbe75b08 100644 --- a/test/ability_anchor_scaling_test.dart +++ b/test/ability_anchor_scaling_test.dart @@ -15,7 +15,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: 'anchor-test', id: 'anchor-test', diff --git a/test/action_history_hydration_test.dart b/test/action_history_hydration_test.dart index 6f8eb6b5..c8250088 100644 --- a/test/action_history_hydration_test.dart +++ b/test/action_history_hydration_test.dart @@ -9,7 +9,6 @@ import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { diff --git a/test/action_provider_bulk_clear_test.dart b/test/action_provider_bulk_clear_test.dart index 6c7e8fac..511b043c 100644 --- a/test/action_provider_bulk_clear_test.dart +++ b/test/action_provider_bulk_clear_test.dart @@ -16,7 +16,6 @@ import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { diff --git a/test/convex_generated_client_test.dart b/test/convex_generated_client_test.dart index 882c6f20..1efa2b83 100644 --- a/test/convex_generated_client_test.dart +++ b/test/convex_generated_client_test.dart @@ -230,6 +230,9 @@ ConvexArray _folderTreeValue({ 'publicId': const ConvexString('folder-1'), 'role': ConvexString(role), 'updatedAt': const ConvexInteger(2), + 'strategyCount': const ConvexInteger(0), + 'mapPeeks': ConvexArray(const []), + 'agentTypes': ConvexArray(const []), }), ]); diff --git a/test/custom_shape_color_test.dart b/test/custom_shape_color_test.dart index ea8c3d65..8c473c22 100644 --- a/test/custom_shape_color_test.dart +++ b/test/custom_shape_color_test.dart @@ -9,7 +9,7 @@ import 'package:icarus/providers/utility_provider.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: null, id: 'shape-color-test', diff --git a/test/custom_shape_indicator_test.dart b/test/custom_shape_indicator_test.dart index 26a4c360..d0b9dc54 100644 --- a/test/custom_shape_indicator_test.dart +++ b/test/custom_shape_indicator_test.dart @@ -16,13 +16,13 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_circle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_rectangle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/shape_indicator_fade.dart'; import 'package:icarus/widgets/mouse_watch.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class _FixedMapProvider extends MapProvider { @override @@ -204,7 +204,7 @@ void main() { .every((indicator) => indicator.visible), isTrue, ); - expect(find.byIcon(Icons.rotate_right_rounded), findsOneWidget); + expect(find.byIcon(LucideIcons.rotateCw), findsOneWidget); final handle = find.byKey( const ValueKey('custom-rectangle-rotate-top-center'), @@ -231,7 +231,7 @@ void main() { final activeBadgeIcon = tester.widget( find.descendant( of: handle, - matching: find.byIcon(Icons.rotate_right_rounded), + matching: find.byIcon(LucideIcons.rotateCw), ), ); expect( diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart index dfc9ad5b..2df06797 100644 --- a/test/discord_presence_service_test.dart +++ b/test/discord_presence_service_test.dart @@ -8,7 +8,7 @@ void main() { group('DiscordPresenceData', () { test('uses a generic library presence when no strategy is open', () { final presence = DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: true, stratName: null, id: 'testID', @@ -24,7 +24,7 @@ void main() { test('shares map and side without sharing the strategy name', () { final presence = DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: false, stratName: 'Secret tournament execute', id: 'strategy-id', @@ -46,7 +46,7 @@ void main() { test('summarizes what is on the board', () { DiscordPresenceData build({int agents = 0, int abilities = 0}) => DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: false, stratName: 'A-site rush', id: 'strategy-id', diff --git a/test/drawing_provider_test.dart b/test/drawing_provider_test.dart index 698c001d..56ba56c2 100644 --- a/test/drawing_provider_test.dart +++ b/test/drawing_provider_test.dart @@ -11,7 +11,6 @@ import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { diff --git a/test/hive_store_launch_test.dart b/test/hive_store_launch_test.dart new file mode 100644 index 00000000..0e0b4217 --- /dev/null +++ b/test/hive_store_launch_test.dart @@ -0,0 +1,233 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/startup/hive_store_launch.dart'; +import 'package:path/path.dart' as path; + +void main() { + group('HiveStoreLaunch.parse', () { + test('keeps file arguments unchanged when no option is present', () { + final launch = HiveStoreLaunch.parse(['first.ica', 'second.ica']); + + expect(launch.fileOpenArgs, ['first.ica', 'second.ica']); + }); + + test('consumes the separate option and value', () { + final launch = HiveStoreLaunch.parse([ + 'first.ica', + HiveStoreLaunch.optionName, + '/tmp/icarus-demo', + 'second.ica', + ]); + + expect(launch.fileOpenArgs, ['first.ica', 'second.ica']); + }); + + test('consumes the equals form', () { + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/icarus-demo', + 'strategy.ica', + ]); + + expect(launch.fileOpenArgs, ['strategy.ica']); + }); + + test('rejects duplicate options', () { + expect( + () => HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/one', + HiveStoreLaunch.optionName, + '/tmp/two', + ]), + throwsA(isA()), + ); + }); + + test('rejects missing, empty, and NUL values', () { + for (final arguments in [ + [HiveStoreLaunch.optionName], + ['${HiveStoreLaunch.optionName}='], + ['${HiveStoreLaunch.optionName}=/tmp/bad\u0000path'], + ]) { + expect( + () => HiveStoreLaunch.parse(arguments), + throwsA(isA()), + ); + } + }); + }); + + group('HiveStoreLaunch.prepareAlternateStore', () { + late Directory testRoot; + + setUp(() async { + testRoot = await Directory.systemTemp.createTemp('icarus-hive-launch-'); + }); + + tearDown(() async { + if (await testRoot.exists()) { + await testRoot.delete(recursive: true); + } + }); + + test('does no filesystem work without an alternate option', () async { + var requestedDefault = false; + var probedDirectory = false; + final launch = HiveStoreLaunch.parse(const []); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async { + requestedDefault = true; + return testRoot; + }, + probeDirectory: (_) async { + probedDirectory = true; + }, + ); + + expect(prepared, isNull); + expect(requestedDefault, isFalse); + expect(probedDirectory, isFalse); + }); + + test('rejects a relative path before filesystem work', () async { + var requestedDefault = false; + var probedDirectory = false; + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + 'relative/demo-hive', + ]); + + await expectLater( + launch.prepareAlternateStore( + getDefaultHiveDirectory: () async { + requestedDefault = true; + return testRoot; + }, + probeDirectory: (_) async { + probedDirectory = true; + }, + ), + throwsA(isA()), + ); + expect(requestedDefault, isFalse); + expect(probedDirectory, isFalse); + }); + + test('creates, resolves, and probes an alternate directory', () async { + final requestedDirectory = Directory( + path.join(testRoot.path, 'nested', '..', 'demo-hive'), + ); + Directory? probedDirectory; + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=${requestedDirectory.path}', + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => + Directory(path.join(testRoot.path, 'default-hive')), + probeDirectory: (directory) async { + probedDirectory = directory; + }, + ); + + final resolvedRequestedPath = path.normalize( + await Directory( + path.normalize(requestedDirectory.path), + ).resolveSymbolicLinks(), + ); + expect(prepared, isNotNull); + expect(prepared!.hiveDirectoryPath, resolvedRequestedPath); + expect(probedDirectory?.path, resolvedRequestedPath); + expect(await Directory(resolvedRequestedPath).exists(), isTrue); + expect( + prepared.windowsSingleInstanceId, + matches(RegExp(r'^icarus_single_instance_[0-9a-f]{64}$')), + ); + }); + + test('uses the legacy instance id for the default directory', () async { + final defaultDirectory = Directory(path.join(testRoot.path, 'default')); + await defaultDirectory.create(); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + path.join(defaultDirectory.path, '.'), + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => defaultDirectory, + probeDirectory: (_) async {}, + ); + + expect( + prepared?.windowsSingleInstanceId, + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); + }); + + test('maps a symlink alias of the default directory to the legacy id', + () async { + if (Platform.isWindows) return; + + final defaultDirectory = Directory(path.join(testRoot.path, 'default')); + await defaultDirectory.create(); + final alias = Link(path.join(testRoot.path, 'default-alias')); + await alias.create(defaultDirectory.path); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + alias.path, + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => defaultDirectory, + probeDirectory: (_) async {}, + ); + + expect( + prepared?.windowsSingleInstanceId, + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); + }); + + test('wraps an unusable-directory failure without falling back', () async { + final requestedDirectory = Directory(path.join(testRoot.path, 'blocked')); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + requestedDirectory.path, + ]); + + await expectLater( + launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => testRoot, + probeDirectory: (_) async { + throw const FileSystemException('blocked'); + }, + ), + throwsA( + isA().having( + (error) => error.cause, + 'cause', + isA(), + ), + ), + ); + }); + }); + + group('HiveStoreLaunch.validateForWeb', () { + test('allows the default IndexedDB store', () { + expect(HiveStoreLaunch.parse(const []).validateForWeb, returnsNormally); + }); + + test('rejects a filesystem override', () { + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/icarus-demo', + ]); + + expect( + launch.validateForWeb, + throwsA(isA()), + ); + }); + }); +} diff --git a/test/library_entries_test.dart b/test/library_entries_test.dart index 3adacd3e..2dd4c65c 100644 --- a/test/library_entries_test.dart +++ b/test/library_entries_test.dart @@ -5,6 +5,7 @@ import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/library_entries.dart'; void main() { @@ -17,7 +18,8 @@ void main() { _localStrategy('local-only', 'Mine'), showDeviceBadge: true, ); - final cloud = LibraryStrategyRow.cloud(_cloudStrategy('shared-id', 'Cloud')); + final cloud = + LibraryStrategyRow.cloud(_cloudStrategy('shared-id', 'Cloud')); final merged = mergeLibraryStrategies( local: [local, onlyLocal], @@ -100,14 +102,16 @@ CloudStrategyEntry _cloudStrategy(String id, String name, {DateTime? created}) { } LibraryFolderRow _folderRow(String id, LibraryWorkspace store) { + final folder = Folder( + name: id, + id: id, + dateCreated: DateTime(2024, 1, 1), + color: FolderColor.blue, + ); return LibraryFolderRow( - folder: Folder( - name: id, - id: id, - dateCreated: DateTime(2024, 1, 1), - color: FolderColor.blue, - ), + folder: folder, store: store, lastUpdated: DateTime(2024, 1, 1), + card: FolderCardViewData.summary(folder: folder, folderCount: 0), ); } diff --git a/test/providers/remote_library_provider_test.dart b/test/providers/remote_library_provider_test.dart index 79e8d26f..407b9e70 100644 --- a/test/providers/remote_library_provider_test.dart +++ b/test/providers/remote_library_provider_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/generated/generated.dart'; @@ -34,6 +35,9 @@ void main() { 'createdAt': 1700000000000, 'updatedAt': 1700000001000, 'role': 'owner', + 'strategyCount': 2, + 'mapPeeks': ['haven', 'ascent'], + 'agentTypes': ['sova', 'jett', 'not-an-agent-yet'], }, ]), ); @@ -46,6 +50,10 @@ void main() { expect(folders.single.folder.color, FolderColor.blue); expect(folders.single.folder.customColor?.toARGB32(), 0xff123456); expect(folders.single.role, 'owner'); + expect(folders.single.strategyCount, 2); + expect(folders.single.mapPeeks, [MapValue.haven, MapValue.ascent]); + // Unknown agent names from a newer server are skipped, not fatal. + expect(folders.single.agentTypes, [AgentType.sova, AgentType.jett]); }); test('repository maps typed strategy rows into Icarus strategies', () async { @@ -126,6 +134,9 @@ void main() { dateCreated: DateTime(2026), ), role: 'owner', + strategyCount: 0, + mapPeeks: const [], + agentTypes: const [], ), ( folder: Folder( @@ -134,6 +145,9 @@ void main() { dateCreated: DateTime(2026), ), role: 'editor', + strategyCount: 0, + mapPeeks: const [], + agentTypes: const [], ), ]); for (var i = 0; diff --git a/test/rectangle_axis_resize_geometry_test.dart b/test/rectangle_axis_resize_geometry_test.dart index 422ba4ef..a34c46eb 100644 --- a/test/rectangle_axis_resize_geometry_test.dart +++ b/test/rectangle_axis_resize_geometry_test.dart @@ -13,7 +13,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/rectangle_axis_resize class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: null, id: 'axis-resize-test', diff --git a/test/screenshot_view_test.dart b/test/screenshot_view_test.dart index 71f7bfc7..2ea21326 100644 --- a/test/screenshot_view_test.dart +++ b/test/screenshot_view_test.dart @@ -16,7 +16,6 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; @@ -256,7 +255,7 @@ void main() { testWidgets('pre-hydrated screenshot providers render without build writes', (tester) async { - final strategyState = StrategyState( + const strategyState = StrategyState( isSaved: true, stratName: 'test strategy', id: 'strategy-id', diff --git a/test/strategy_integrity_test.dart b/test/strategy_integrity_test.dart index b0431e68..8fac10f5 100644 --- a/test/strategy_integrity_test.dart +++ b/test/strategy_integrity_test.dart @@ -32,7 +32,6 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; class _IcaFixture { diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index c026a315..1cfd023a 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -31,7 +31,6 @@ import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/transition_provider.dart' hide PageTransitionState; import 'package:icarus/providers/user_preferences_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _FakeRemoteEditorNotifier extends RemoteEditorSnapshotNotifier { diff --git a/test/strategy_provider_save_queue_test.dart b/test/strategy_provider_save_queue_test.dart index 2523b9a8..5cd68ce4 100644 --- a/test/strategy_provider_save_queue_test.dart +++ b/test/strategy_provider_save_queue_test.dart @@ -10,7 +10,7 @@ class _ControlledSaveStrategyProvider extends StrategyProvider { int saveCalls = 0; @override - StrategyState build() => StrategyState( + StrategyState build() => const StrategyState( isSaved: false, stratName: 'Strategy', id: 'strategy-id', diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart index 2d724b89..4b3eb7a4 100644 --- a/test/strategy_view_skeleton_test.dart +++ b/test/strategy_view_skeleton_test.dart @@ -37,12 +37,17 @@ void main() { debugDefaultTargetPlatformOverride = null; } + final strip = tester.getRect(find.byType(AppWindowStrip)); final mapThumbnail = tester.getRect(find.byType(Image).first); final strategyTitle = tester.getRect(find.text('SYNC BOUNDARY PROBE')); final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); - expect(strategyTitle.center.dy, mapThumbnail.center.dy); - expect(captionButtons.center.dy, mapThumbnail.center.dy); + // Title and caption buttons share the strip; the map card sits on the + // canvas below it, like the real editor. + expect(strip.height, kWindowStripHeight); + expect(strategyTitle.center.dy, closeTo(strip.center.dy, 0.5)); + expect(captionButtons.center.dy, closeTo(strip.center.dy, 0.5)); + expect(mapThumbnail.top, greaterThan(strip.bottom)); expect(tester.takeException(), isNull); }); } diff --git a/test/text_provider_test.dart b/test/text_provider_test.dart index 5772ad1a..3f6bd148 100644 --- a/test/text_provider_test.dart +++ b/test/text_provider_test.dart @@ -18,7 +18,6 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopActionProvider extends ActionProvider { diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 406248e8..d6c46498 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -21,7 +21,6 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index b4f1608d..c546f7b1 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -28,7 +28,6 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -521,7 +520,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Save changes?'), findsOneWidget); - await tester.tap(find.text("Don't Save")); + await tester.tap(find.text("Don't save")); await tester.pumpAndSettle(); final result = await guardFuture; @@ -617,13 +616,13 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsOneWidget); + expect(find.text('Leave anyway'), findsOneWidget); expect( find.textContaining('have not reached the cloud'), findsOneWidget, ); expect(opQueue.currentState.pending, hasLength(1)); - await tester.tap(find.text('Leave Anyway')); + await tester.tap(find.text('Leave anyway')); await tester.pumpAndSettle(); expect(await guardFuture, isTrue); @@ -674,8 +673,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsOneWidget); - await tester.tap(find.text('Leave Anyway')); + expect(find.text('Leave anyway'), findsOneWidget); + await tester.tap(find.text('Leave anyway')); await tester.pumpAndSettle(); expect(await guardFuture, isTrue); @@ -724,9 +723,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsNothing); - expect(find.text('Stay Here'), findsOneWidget); - await tester.tap(find.text('Stay Here')); + expect(find.text('Leave anyway'), findsNothing); + expect(find.text('Stay here'), findsOneWidget); + await tester.tap(find.text('Stay here')); await tester.pumpAndSettle(); expect(await guardFuture, isFalse); }); @@ -784,8 +783,8 @@ void main() { }, ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsNothing); - await tester.tap(find.text('Stay Here')); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); await tester.pumpAndSettle(); expect(await guarded, isFalse); expect(continued, isFalse); @@ -859,9 +858,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsOneWidget); + expect(find.text('Leave anyway'), findsOneWidget); expect(find.textContaining('will not delete'), findsOneWidget); - await tester.tap(find.text('Leave Anyway')); + await tester.tap(find.text('Leave anyway')); await tester.pumpAndSettle(); expect(await guardFuture, isTrue); expect(continueCalls, 1); @@ -926,8 +925,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Leave Anyway'), findsNothing); - await tester.tap(find.text('Stay Here')); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); await tester.pumpAndSettle(); expect(await guardFuture, isFalse); }); diff --git a/test/vision_boundary_editor_widget_test.dart b/test/vision_boundary_editor_widget_test.dart index 27d22c05..aa96b6c9 100644 --- a/test/vision_boundary_editor_widget_test.dart +++ b/test/vision_boundary_editor_widget_test.dart @@ -73,7 +73,7 @@ void main() { VisionBoundaryEditScope.all, ); - await tester.tap(find.byIcon(Icons.keyboard_arrow_right)); + await tester.tap(find.byIcon(LucideIcons.chevronRight)); await tester.pumpAndSettle(); var state = container.read(visionBoundaryEditorProvider); expect(state.draft!.outer.first, initial.outer.first + const Offset(1, 0)); diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index da75ed68..f1ff1e18 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -107,6 +107,12 @@ void main() { testWidgets('library strip exposes stable destinations while signed out', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_testApp(_strip())); expect(find.byKey(const ValueKey('library-tab-library')), findsOneWidget); @@ -131,6 +137,12 @@ void main() { }); testWidgets('signed-out Shared tab opens login', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_testApp(_strip())); await tester.tap(find.byKey(const ValueKey('library-tab-shared'))); @@ -141,6 +153,12 @@ void main() { }); testWidgets('New menu offers a strategy and a folder', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var created = 0; await tester.pumpWidget( _testApp(_strip(onCreateStrategy: () => created++)), @@ -158,6 +176,12 @@ void main() { expect(created, 1); }); testWidgets('library account action uses guarded sign out', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var requests = 0; await tester.pumpWidget( ProviderScope( diff --git a/test/widgets/cloud_sync_status_chip_test.dart b/test/widgets/cloud_sync_button_test.dart similarity index 89% rename from test/widgets/cloud_sync_status_chip_test.dart rename to test/widgets/cloud_sync_button_test.dart index 4dd40202..90713ee2 100644 --- a/test/widgets/cloud_sync_status_chip_test.dart +++ b/test/widgets/cloud_sync_button_test.dart @@ -13,7 +13,8 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/cloud_sync_status_chip.dart'; +import 'package:icarus/widgets/cloud_sync_button.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class _CloudStrategyProvider extends StrategyProvider { @@ -218,6 +219,17 @@ ProviderContainer _createConflictContainer({ ); } +Finder _syncButton(String status) => + find.byKey(ValueKey('cloud-sync-button-$status')); + +/// The one-line meaning the button shows on hover. +String _syncTooltip(WidgetTester tester) { + final button = tester.widget( + find.byType(EditorToolbarButton), + ); + return button.tooltip; +} + void main() { test('restored active-strategy media renders as syncing', () { final container = _createContainer( @@ -284,13 +296,14 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - expect(find.text('Synced'), findsOneWidget); + expect(_syncButton('synced'), findsOneWidget); container .read(textDraftProvider.notifier) @@ -298,18 +311,10 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Editing…'), findsOneWidget); - expect(find.text('Synced'), findsNothing); + expect(_syncButton('editing'), findsOneWidget); + expect(_syncButton('synced'), findsNothing); - await tester.tap(find.text('Editing…')); - await tester.pumpAndSettle(); - expect(find.text('Edit not synced yet'), findsOneWidget); - expect( - find.text( - 'Finish editing or switch pages to send this change to the cloud.', - ), - findsOneWidget, - ); + expect(_syncTooltip(tester), 'Edit not synced yet'); }); testWidgets('offline remains visible while a text draft is active', @@ -324,16 +329,17 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Offline'), findsOneWidget); - expect(find.text('Editing…'), findsNothing); - expect(find.text('Synced'), findsNothing); + expect(_syncButton('offline'), findsOneWidget); + expect(_syncButton('editing'), findsNothing); + expect(_syncButton('synced'), findsNothing); }); test('status provider prioritizes connectivity over an offline flush error', @@ -443,14 +449,15 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - expect(find.text('Needs attention'), findsOneWidget); - await tester.tap(find.text('Needs attention')); + expect(_syncButton('attention'), findsOneWidget); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect( find.text( @@ -501,15 +508,16 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Offline'), findsOneWidget); - expect(find.text('Needs attention'), findsNothing); + expect(_syncButton('offline'), findsOneWidget); + expect(_syncButton('attention'), findsNothing); }); testWidgets('durability uncertainty never appears synced or reliable', @@ -528,7 +536,8 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); @@ -536,10 +545,10 @@ void main() { final queue = container.read(strategyOpQueueProvider); expect(queue.outboxIsReliable, isFalse); - expect(find.text('Needs attention'), findsOneWidget); - expect(find.text('Synced'), findsNothing); + expect(_syncButton('attention'), findsOneWidget); + expect(_syncButton('synced'), findsNothing); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Retry sync'), findsOneWidget); expect(find.textContaining('safely stored'), findsNothing); @@ -559,12 +568,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Use cloud'), findsOneWidget); @@ -600,12 +610,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Use cloud'), findsOneWidget); @@ -631,12 +642,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect(find.text('Use cloud'), findsOneWidget); @@ -667,12 +679,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); expect( @@ -698,12 +711,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); await tester.tap(find.text('Use cloud')); await tester.pumpAndSettle(); @@ -734,12 +748,13 @@ void main() { UncontrolledProviderScope( container: container, child: const ShadApp( - home: Scaffold(body: CloudSyncStatusChip()), + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), ), ), ); await tester.pump(); - await tester.tap(find.text('Needs attention')); + await tester.tap(_syncButton('attention')); await tester.pumpAndSettle(); await tester.tap(find.text('Use cloud')); await tester.pumpAndSettle(); diff --git a/test/widgets/inset_shadow_decoration_test.dart b/test/widgets/inset_shadow_decoration_test.dart new file mode 100644 index 00000000..b606a33a --- /dev/null +++ b/test/widgets/inset_shadow_decoration_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; + +const _size = Size(120, 40); +const _fill = Color(0xff27272a); + +Future<_Pixels> _render(WidgetTester tester, Decoration decoration) async { + final key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: _size.width, + height: _size.height + 1, + child: Align( + alignment: Alignment.topCenter, + child: Container( + width: _size.width, + height: _size.height, + decoration: decoration, + ), + ), + ), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + // Engine futures only resolve inside runAsync; the fake-async test zone + // never completes them. + late _Pixels pixels; + await tester.runAsync(() async { + final image = await boundary.toImage(); + final dump = Platform.environment['INSET_SHADOW_DUMP']; + if (dump != null) { + final png = await image.toByteData(format: ui.ImageByteFormat.png); + File(dump).writeAsBytesSync(png!.buffer.asUint8List()); + } + pixels = _Pixels(image.width, (await image.toByteData())!); + }); + return pixels; +} + +class _Pixels { + const _Pixels(this.width, this.bytes); + + final int width; + final ByteData bytes; + + Color at(int x, int y) { + final i = (y * width + x) * 4; + return Color.fromARGB( + bytes.getUint8(i + 3), + bytes.getUint8(i), + bytes.getUint8(i + 1), + bytes.getUint8(i + 2), + ); + } +} + +void main() { + testWidgets('raised rim lightens the edges and the top edge most', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + shadows: Settings.raisedRim, + ), + ); + final centre = image.at(60, 20); + final top = image.at(60, 0); + final bottom = image.at(60, 39); + final left = image.at(0, 20); + final inside = image.at(60, 2); + + expect(centre, _fill, reason: 'the face keeps the plain fill'); + expect(inside, _fill, reason: 'the edges are one pixel deep'); + expect(left, _fill, reason: 'the sides stay bare'); + expect(top.r, greaterThan(centre.r), reason: 'top edge is lit'); + expect(bottom.r, lessThan(centre.r), reason: 'bottom edge is shaded'); + }); + + testWidgets('gradient paints over the fill and drop shadows fall outside', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + gradient: Settings.raisedGradient(_fill), + borderRadius: BorderRadius.circular(6), + boxShadows: const [Settings.raisedDropShadow], + ), + ); + expect(image.at(60, 4).r, greaterThan(image.at(60, 35).r), + reason: 'the fill runs lighter at the top'); + expect(image.at(60, 40).a, greaterThan(0), + reason: 'the shadow lands one pixel under the box'); + }); + + testWidgets('a blurred inset shadow darkens toward the edge', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration( + color: Color(0xffffffff), + shadows: [ + InsetShadow(color: Color(0xff000000), blurRadius: 8), + ], + ), + ); + final centre = image.at(60, 20); + final nearEdge = image.at(60, 1); + final midway = image.at(60, 4); + expect(centre, const Color(0xffffffff)); + expect(nearEdge.r, lessThan(midway.r)); + expect(midway.r, lessThan(centre.r)); + }); + + testWidgets('without shadows nothing paints outside the fill', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration(color: _fill), + ); + expect(image.at(0, 0), _fill); + expect(image.at(119, 39), _fill); + }); + + test('tweens from a plain rounded BoxDecoration', () { + final from = BoxDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + ); + final to = Settings.raisedPrimary(6); + final mid = Decoration.lerp(from, to, 0.5); + expect(mid, isA()); + final raised = mid! as InsetShadowDecoration; + expect(raised.shadows, hasLength(2)); + expect(raised.shadows.first.color.a, closeTo(0.07, 0.01), + reason: 'the top light fades in halfway'); + expect(Decoration.lerp(to, from, 0.75), isA()); + }); +} diff --git a/test/widgets/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart index ab520206..0fff7179 100644 --- a/test/widgets/strategy_quick_switcher_layout_test.dart +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -49,18 +49,8 @@ void main() { home: const Scaffold( body: Align( alignment: Alignment.topCenter, - child: EditorWindowHeader( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - key: ValueKey('map-card-reference'), - width: 262, - height: 65, - ), - StrategyQuickSwitcher(), - ], - ), + child: AppWindowStrip( + child: Center(child: StrategyQuickSwitcher()), ), ), ), @@ -75,16 +65,14 @@ void main() { final control = tester.getRect( find.byKey(const ValueKey('strategy-quick-switcher-control')), ); - final mapCard = tester.getRect( - find.byKey(const ValueKey('map-card-reference')), - ); final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); - final header = tester.getRect(find.byType(EditorWindowHeader)); + final strip = tester.getRect(find.byType(AppWindowStrip)); - expect(control.center.dy, mapCard.center.dy); - expect(captionButtons.center.dy, mapCard.center.dy); - expect(mapCard.top - header.top, header.bottom - mapCard.bottom); - expect(control.height, 40); + expect(strip.height, kWindowStripHeight); + // The strip's 1px bottom border sits outside its content box. + expect(control.center.dy, closeTo(strip.center.dy, 0.5)); + expect(captionButtons.center.dy, closeTo(strip.center.dy, 0.5)); + expect(control.height, 30); expect(tester.takeException(), isNull); }); }