diff --git a/.agent/skills/gopeed-file-category-icons/.gitignore b/.agent/skills/gopeed-file-category-icons/.gitignore new file mode 100644 index 00000000..cbab8c3c --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/.gitignore @@ -0,0 +1,3 @@ +.venv/ +.build/ +__pycache__/ diff --git a/.agent/skills/gopeed-file-category-icons/SKILL.md b/.agent/skills/gopeed-file-category-icons/SKILL.md new file mode 100644 index 00000000..02bf3027 --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/SKILL.md @@ -0,0 +1,59 @@ +--- +name: gopeed-file-category-icons +description: Design or revise Gopeed file-classification icons as one cohesive SVG-to-Flutter icon font. Use for file types, generic and protocol-marked folders, unknown files, and extension-to-icon taxonomy; do not use for launcher, navigation, action, or status icons. +--- + +# Gopeed File Category Icons + +Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline. + +The canonical editable materials live in `design/file-category-icons/`, outside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets. + +## Classification grammar + +- A directory is always a folder outline. Add a classification or protocol mark only when one is known; otherwise leave the folder empty. +- A non-directory is always a file outline plus its classification mark. +- An unknown file is an empty file outline. Do not invent a generic interior mark. +- Transfer state is separate from classification. Never replace a file glyph with paused, failed, completed, or downloading artwork. +- Keep this skill scoped to file browsing, task assets, extension classification, and the font that renders those categories. + +## Workflow + +1. Read `design/file-category-icons/README.md`, then inspect its manifest, the classification mapping, real task-card size, and file-tree size. Read [references/style-guide.md](references/style-guide.md) for every visual revision. +2. Preserve the shared file or folder shell. Redraw only the classification mark unless the task explicitly revises the complete system. +3. Create 24×24 SVG sources containing only closed, filled `` geometry. Convert strokes and lettering to paths; never leave ``, live strokes, masks, clipping, filters, or external references. +4. Keep every decorated mark inside the documented safe area. Reject any mark that touches or visually merges with the shell at 16 or 20 px. +5. Preserve existing names and codepoints. Omit the codepoint only for a new category, then allocate it with the builder. Bump the font version for a visual revision. +6. Rebuild the complete TTF and generated Dart class, then generate the multi-size contact sheet. +7. Inspect 16, 20, 24, and 32 px in light and dark swatches. Check the actual Flutter task card and task-detail file tree when their sizes differ from the sheet. +8. Run the relevant classification tests, `flutter analyze`, and `flutter test` after integration changes. + +## Tool setup + +The scripts require the pinned packages in `scripts/requirements.txt`. Keep the environment local and untracked: + +```bash +python3 -m venv .agent/skills/gopeed-file-category-icons/.venv +.agent/skills/gopeed-file-category-icons/.venv/bin/pip install -r .agent/skills/gopeed-file-category-icons/scripts/requirements.txt +``` + +Build and preview a manifest: + +```bash +.agent/skills/gopeed-file-category-icons/.venv/bin/python .agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py --manifest design/file-category-icons/manifest.json --allocate-codepoints +.agent/skills/gopeed-file-category-icons/.venv/bin/python .agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py --manifest design/file-category-icons/manifest.json +``` + +Use [assets/manifest.example.json](assets/manifest.example.json) and [assets/folder-symbol-template.svg](assets/folder-symbol-template.svg) only as structural starting points. + +## Invariants + +- Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration. +- `design/file-category-icons/` is the sole editable source of truth. Flutter keeps only the generated runtime TTF and Dart constants. +- Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph. +- The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font. +- Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields. +- File and folder shells in the same revision must share outline weight, baseline, fold/tab geometry, and optical scale. +- Prefer a recognizable classification symbol over lettering. If lettering is necessary, convert it to geometry and prove it remains legible at 16 px; PDF-like multi-letter marks need deliberate spacing rather than scaling text into the available area. +- New classification groups require both an extension/domain mapping and a glyph. Do not add a glyph that no classifier can reach. +- Rebuild the complete font after every source change so all platforms consume the same binary. diff --git a/.agent/skills/gopeed-file-category-icons/agents/openai.yaml b/.agent/skills/gopeed-file-category-icons/agents/openai.yaml new file mode 100644 index 00000000..81a6b810 --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Gopeed File Category Icons" + short_description: "Design cohesive Gopeed file-classification icons" + default_prompt: "Use $gopeed-file-category-icons to update the source materials and regenerate a consistent Gopeed file-category icon." diff --git a/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg b/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg new file mode 100644 index 00000000..d9fe2428 --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json b/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json new file mode 100644 index 00000000..3c57c774 --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json @@ -0,0 +1,20 @@ +{ + "fontFamily": "GopeedIcons", + "fontOutput": "../.build/GopeedIcons.ttf", + "dartOutput": "../.build/gopeed_icons.dart", + "previewOutput": "../.build/gopeed_icons_preview.png", + "dartClass": "GopeedIcons", + "codepointStart": "0xE900", + "unitsPerEm": 1024, + "ascent": 896, + "descent": -128, + "padding": 0, + "version": "1.0.0", + "icons": [ + { + "name": "file_category_template", + "source": "folder-symbol-template.svg", + "codepoint": "0xE900" + } + ] +} diff --git a/.agent/skills/gopeed-file-category-icons/references/style-guide.md b/.agent/skills/gopeed-file-category-icons/references/style-guide.md new file mode 100644 index 00000000..d32fd162 --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/references/style-guide.md @@ -0,0 +1,44 @@ +# Gopeed file-category icon style + +## Composition + +- Unknown file: empty file shell. +- Known file: file shell plus one classification mark. +- Generic folder: empty folder shell. +- Known or protocol folder: folder shell plus one classification mark. +- Status and actions never belong inside a classification glyph. + +## Geometry + +- Use a `0 0 24 24` viewBox and keep the optical drawing inside `2…22` on both axes. +- Use the current shared shell as the source of truth. The established file shell spans roughly `x=3…21` with a 1.5-unit outline; do not silently introduce a heavier one-off border. +- Keep decorated marks approximately inside `x=6.2…17.8` and `y=9.2…19`. Preserve at least 1.5 units of visible separation from the shell wherever its interior edge is closer. +- Prefer radii around 1.5–2 units, consistent terminal shapes, and simple silhouettes. Avoid details or gaps below 1.25 units. +- Center by optical mass rather than bounding-box arithmetic when the shape has a tab, arrow, or asymmetric label. +- Use non-zero winding for counters and holes; reverse inner-contour direction where a cutout is required. + +## Classification marks + +- Use one dominant mark, not several miniature objects. +- Prefer semantic geometry: play for video, note for audio, image silhouette for images, grid for spreadsheets, a zipper for archive/compressed files, and a cylinder for databases. +- Avoid lettering when a semantic mark is clearer. When a conventional label such as PDF is retained, build each letter as filled geometry with at least 1.25-unit stems and explicit inter-letter gaps. +- Keep an empty file truly empty. A question mark, star, or extension text changes its meaning. + +## Protocol-marked folders + +- Keep the folder silhouette recognizable before adding the protocol symbol. +- Place a simple filled protocol symbol in the lower folder body with clear space from every edge. +- Prefer semantic marks such as a magnet/swarm for BitTorrent or a donkey/server-network mark for ED2K. Do not default to acronym lettering. +- Let the visible folder occupy about 80–85% of the 24-unit canvas. Avoid adding generator padding on top of the SVG optical margin; double padding makes Flutter icons look undersized. +- Compare optical center and occupied area against Flutter's `folder_outlined` at the actual widget size, not only in an enlarged preview. + +## Consistency checks + +- Compare new icons beside at least one existing folder icon and one file-type icon. +- Inspect the generated contact sheet at 16, 20, 24, and 32 px on light and dark swatches. +- Reject icons that depend on color, contain live text/strokes, have inconsistent baseline/weight, drift off center, become ambiguous at 16 px, or leave less than the shared safe-area separation from the shell. +- Compare decorated icons against both empty shells. Interior marks must not make the shell look heavier by merging into its border. + +## Flutter usage + +Register the generated TTF under `flutter.fonts` using the exact `fontFamily` from the manifest. Use the generated Dart constants rather than raw codepoints. Render icon-font glyphs with `Icon`; do not use `Text` because icon sizing and semantics should follow Flutter's icon pipeline. diff --git a/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py b/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py new file mode 100644 index 00000000..cc8332ef --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Build a deterministic Gopeed file-category font and Flutter IconData class from SVG paths.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +try: + from fontTools.fontBuilder import FontBuilder + from fontTools.pens.boundsPen import BoundsPen + from fontTools.pens.cu2quPen import Cu2QuPen + from fontTools.pens.transformPen import TransformPen + from fontTools.pens.ttGlyphPen import TTGlyphPen + from fontTools.svgLib.path import SVGPath +except ImportError as error: + raise SystemExit("Missing fonttools; install scripts/requirements.txt in the skill's .venv") from error + + +NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +DART_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") +PRIVATE_USE_START = 0xE000 +PRIVATE_USE_END = 0xF8FF +ALLOWED_SVG_TAGS = {"svg", "g", "path"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--allocate-codepoints", action="store_true") + return parser.parse_args() + + +def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def parse_number(value: object, field: str) -> int: + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value, 0) + except ValueError as error: + raise ValueError(f"{field} must be an integer or 0x-prefixed integer") from error + raise ValueError(f"{field} must be an integer or string") + + +def parse_view_box(svg_text: str, source: Path) -> tuple[float, float, float, float]: + try: + root = ET.fromstring(svg_text) + except ET.ParseError as error: + raise ValueError(f"{source}: invalid XML: {error}") from error + if local_name(root.tag) != "svg": + raise ValueError(f"{source}: root element must be ") + values = re.split(r"[\s,]+", root.attrib.get("viewBox", "").strip()) + if len(values) != 4: + raise ValueError(f"{source}: viewBox must contain four numbers") + try: + min_x, min_y, width, height = (float(value) for value in values) + except ValueError as error: + raise ValueError(f"{source}: viewBox contains a non-numeric value") from error + if width <= 0 or height <= 0: + raise ValueError(f"{source}: viewBox dimensions must be positive") + if abs(width - height) > 1e-6: + raise ValueError(f"{source}: icon viewBox must be square") + + path_count = 0 + for element in root.iter(): + tag = local_name(element.tag) + if tag not in ALLOWED_SVG_TAGS: + raise ValueError(f"{source}: unsupported <{tag}>; convert everything to filled paths") + style = element.attrib.get("style", "").replace(" ", "").lower() + stroke = element.attrib.get("stroke", "none").lower() + if stroke != "none" or ("stroke:" in style and "stroke:none" not in style): + raise ValueError(f"{source}: live strokes are not allowed; expand strokes to paths") + if tag == "path": + if not element.attrib.get("d", "").strip(): + raise ValueError(f"{source}: every needs path data") + if element.attrib.get("fill", "").lower() == "none" or "fill:none" in style: + raise ValueError(f"{source}: paths must be filled outlines") + path_count += 1 + if path_count == 0: + raise ValueError(f"{source}: no paths found") + return min_x, min_y, width, height + + +def dart_name(icon: dict[str, object]) -> str: + explicit = icon.get("dartName") + if explicit is not None: + value = str(explicit) + else: + parts = str(icon["name"]).split("_") + value = parts[0] + "".join(part.capitalize() for part in parts[1:]) + if not DART_NAME_PATTERN.fullmatch(value): + raise ValueError(f"invalid Dart field name: {value}") + return value + + +def allocate_codepoints(manifest: dict[str, object], allow_allocation: bool) -> bool: + icons = manifest.get("icons") + if not isinstance(icons, list) or not icons: + raise ValueError("manifest.icons must be a non-empty list") + used = { + parse_number(icon["codepoint"], f"icons[{index}].codepoint") + for index, icon in enumerate(icons) + if isinstance(icon, dict) and icon.get("codepoint") is not None + } + next_codepoint = parse_number(manifest.get("codepointStart", "0xE900"), "codepointStart") + changed = False + for index, icon in enumerate(icons): + if not isinstance(icon, dict): + raise ValueError(f"icons[{index}] must be an object") + if icon.get("codepoint") is not None: + continue + if not allow_allocation: + raise ValueError(f"icons[{index}] has no codepoint; rerun with --allocate-codepoints") + while next_codepoint in used: + next_codepoint += 1 + if next_codepoint > PRIVATE_USE_END: + raise ValueError("no codepoints remain in the BMP private-use area") + icon["codepoint"] = f"0x{next_codepoint:04X}" + used.add(next_codepoint) + next_codepoint += 1 + changed = True + return changed + + +def validate_icons(manifest: dict[str, object], manifest_dir: Path) -> list[dict[str, object]]: + icons = manifest["icons"] + assert isinstance(icons, list) + names: set[str] = set() + dart_names: set[str] = set() + codepoints: set[int] = set() + validated: list[dict[str, object]] = [] + for index, raw_icon in enumerate(icons): + assert isinstance(raw_icon, dict) + name = str(raw_icon.get("name", "")) + if not NAME_PATTERN.fullmatch(name): + raise ValueError(f"icons[{index}].name must be lowercase snake_case") + field_name = dart_name(raw_icon) + codepoint = parse_number(raw_icon.get("codepoint"), f"icons[{index}].codepoint") + if not PRIVATE_USE_START <= codepoint <= PRIVATE_USE_END: + raise ValueError(f"{name}: codepoint must be in U+E000…U+F8FF") + source_value = raw_icon.get("source") + if not isinstance(source_value, str) or not source_value: + raise ValueError(f"{name}: source must be a relative SVG path") + source = (manifest_dir / source_value).resolve() + if source.suffix.lower() != ".svg" or not source.is_file(): + raise ValueError(f"{name}: SVG source does not exist: {source}") + if name in names or field_name in dart_names or codepoint in codepoints: + raise ValueError(f"{name}: duplicate name, Dart field, or codepoint") + names.add(name) + dart_names.add(field_name) + codepoints.add(codepoint) + validated.append( + { + **raw_icon, + "name": name, + "glyphName": f"icon_{name}", + "dartName": field_name, + "codepoint": codepoint, + "sourcePath": source, + } + ) + return sorted(validated, key=lambda icon: int(icon["codepoint"])) + + +def build_font(manifest: dict[str, object], manifest_dir: Path, icons: list[dict[str, object]]) -> Path: + family = str(manifest.get("fontFamily", "GopeedIcons")) + units_per_em = parse_number(manifest.get("unitsPerEm", 1024), "unitsPerEm") + ascent = parse_number(manifest.get("ascent", 896), "ascent") + descent = parse_number(manifest.get("descent", -128), "descent") + padding = parse_number(manifest.get("padding", 96), "padding") + if ascent <= 0 or descent >= 0 or ascent - descent != units_per_em: + raise ValueError("ascent must be positive, descent negative, and their span must equal unitsPerEm") + if padding < 0 or padding * 2 >= units_per_em: + raise ValueError("padding must leave positive drawing space") + + glyph_order = [".notdef", *(str(icon["glyphName"]) for icon in icons)] + glyphs = {} + metrics = {".notdef": (units_per_em, 0)} + empty_pen = TTGlyphPen(None) + glyphs[".notdef"] = empty_pen.glyph() + + for icon in icons: + source = icon["sourcePath"] + assert isinstance(source, Path) + svg_text = source.read_text(encoding="utf-8") + min_x, min_y, width, height = parse_view_box(svg_text, source) + available = units_per_em - padding * 2 + scale = min(available / width, available / height) + glyph_width = width * scale + glyph_height = height * scale + left = (units_per_em - glyph_width) / 2 + bottom = descent + (units_per_em - glyph_height) / 2 + transform = (scale, 0, 0, -scale, left - scale * min_x, bottom + glyph_height + scale * min_y) + glyph_pen = TTGlyphPen(None) + quadratic_pen = Cu2QuPen(glyph_pen, max_err=1.0, reverse_direction=False) + transformed_pen = TransformPen(quadratic_pen, transform) + SVGPath.fromstring(svg_text).draw(transformed_pen) + glyphs[str(icon["glyphName"])] = glyph_pen.glyph() + + bounds_pen = BoundsPen(None) + SVGPath.fromstring(svg_text).draw(TransformPen(bounds_pen, transform)) + if bounds_pen.bounds is None: + raise ValueError(f"{source}: icon produced no drawable bounds") + metrics[str(icon["glyphName"])] = (units_per_em, round(bounds_pen.bounds[0])) + + builder = FontBuilder(units_per_em, isTTF=True) + builder.setupGlyphOrder(glyph_order) + builder.setupCharacterMap({int(icon["codepoint"]): str(icon["glyphName"]) for icon in icons}) + builder.setupGlyf(glyphs) + builder.setupHorizontalMetrics(metrics) + builder.setupHorizontalHeader(ascent=ascent, descent=descent) + version = str(manifest.get("version", "1.0.0")) + postscript_name = re.sub(r"[^A-Za-z0-9-]", "", family.replace(" ", "-")) or "GopeedIcons" + builder.setupNameTable( + { + "familyName": family, + "styleName": "Regular", + "uniqueFontIdentifier": f"{family};{version}", + "fullName": family, + "psName": postscript_name, + "version": f"Version {version}", + } + ) + builder.setupOS2( + sTypoAscender=ascent, + sTypoDescender=descent, + usWinAscent=ascent, + usWinDescent=-descent, + usWeightClass=400, + usWidthClass=5, + ) + builder.setupPost() + builder.setupMaxp() + + output = (manifest_dir / str(manifest["fontOutput"])).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + builder.save(output) + return output + + +def write_dart(manifest: dict[str, object], manifest_dir: Path, icons: list[dict[str, object]]) -> Path: + family = str(manifest.get("fontFamily", "GopeedIcons")) + class_name = str(manifest.get("dartClass", "GopeedIcons")) + if not DART_NAME_PATTERN.fullmatch(class_name): + raise ValueError("dartClass must be a valid Dart class name") + package = manifest.get("fontPackage") + package_argument = "" if package in (None, "") else f", fontPackage: '{package}'" + lines = [ + "// Generated by gopeed-file-category-icons. Do not edit by hand.", + "import 'package:flutter/widgets.dart';", + "", + f"abstract final class {class_name} {{", + f" static const String _fontFamily = '{family}';", + ] + for icon in icons: + lines.append( + f" static const IconData {icon['dartName']} = IconData(0x{int(icon['codepoint']):04x}, fontFamily: _fontFamily{package_argument});" + ) + lines.extend(["}", ""]) + output = (manifest_dir / str(manifest["dartOutput"])).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(lines), encoding="utf-8") + return output + + +def main() -> int: + args = parse_args() + manifest_path = args.manifest.resolve() + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if allocate_codepoints(manifest, args.allocate_codepoints): + manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + manifest_dir = manifest_path.parent + icons = validate_icons(manifest, manifest_dir) + for required in ("fontOutput", "dartOutput"): + if not isinstance(manifest.get(required), str) or not manifest[required]: + raise ValueError(f"manifest.{required} must be a path") + font_output = build_font(manifest, manifest_dir, icons) + dart_output = write_dart(manifest, manifest_dir, icons) + print(f"Built {len(icons)} icons") + print(f"Font: {font_output}") + print(f"Dart: {dart_output}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (KeyError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py b/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py new file mode 100644 index 00000000..6b3db10e --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Render a light/dark multi-size contact sheet for a generated file-category font.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError as error: + raise SystemExit("Missing Pillow; install scripts/requirements.txt in the skill's .venv") from error + + +def parse_number(value: object) -> int: + return value if isinstance(value, int) else int(str(value), 0) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--sizes", default="16,20,24,32") + return parser.parse_args() + + +def draw_centered( + draw: ImageDraw.ImageDraw, + center: tuple[float, float], + char: str, + font: ImageFont.FreeTypeFont, + fill: str, +) -> None: + bounds = draw.textbbox((0, 0), char, font=font) + height = bounds[3] - bounds[1] + advance = draw.textlength(char, font=font) + draw.text( + (center[0] - advance / 2, center[1] - height / 2 - bounds[1]), + char, + font=font, + fill=fill, + ) + + +def main() -> int: + args = parse_args() + manifest_path = args.manifest.resolve() + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_dir = manifest_path.parent + font_path = (manifest_dir / str(manifest["fontOutput"])).resolve() + if not font_path.is_file(): + raise ValueError(f"font does not exist; run the builder first: {font_path}") + sizes = [int(value) for value in args.sizes.split(",") if value.strip()] + if not sizes or any(size <= 0 for size in sizes): + raise ValueError("sizes must contain positive integers") + icons = sorted(manifest["icons"], key=lambda icon: parse_number(icon["codepoint"])) + + label_width = 210 + cell_width = 112 + header_height = 44 + row_height = 78 + image = Image.new( + "RGB", + (label_width + cell_width * len(sizes), header_height + row_height * len(icons)), + "#F8FAFC", + ) + draw = ImageDraw.Draw(image) + label_font = ImageFont.load_default() + draw.text((12, 15), "icon / codepoint", font=label_font, fill="#111827") + for column, size in enumerate(sizes): + x = label_width + column * cell_width + draw.text((x + 42, 15), f"{size}px", font=label_font, fill="#111827") + + for row, icon in enumerate(icons): + y = header_height + row * row_height + codepoint = parse_number(icon["codepoint"]) + draw.text( + (12, y + 29), + f"{icon['name']} U+{codepoint:04X}", + font=label_font, + fill="#111827", + ) + for column, size in enumerate(sizes): + x = label_width + column * cell_width + midpoint = x + cell_width // 2 + draw.rectangle((x, y, midpoint, y + row_height), fill="#FFFFFF") + draw.rectangle((midpoint, y, x + cell_width, y + row_height), fill="#111827") + font = ImageFont.truetype(str(font_path), size=size) + draw_centered(draw, (x + cell_width * 0.25, y + row_height / 2), chr(codepoint), font, "#111827") + draw_centered(draw, (x + cell_width * 0.75, y + row_height / 2), chr(codepoint), font, "#F8FAFC") + + output_value = manifest.get("previewOutput", "file-category-icons-preview.png") + output = (manifest_dir / str(output_value)).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + image.save(output) + print(f"Preview: {output}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (KeyError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt b/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt new file mode 100644 index 00000000..a8428a00 --- /dev/null +++ b/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt @@ -0,0 +1,2 @@ +fonttools==4.59.2 +Pillow==11.3.0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85e42431..c2614237 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ on: default: "false" env: - GO_VERSION: "1.25" + GO_VERSION: "1.25.4" FLUTTER_VERSION: "3.41.6" GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }} @@ -93,7 +93,7 @@ jobs: VERSION: ${{ needs.get-release.outputs.version }} GOARCH: ${{ matrix.arch }} run: | - Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl" + Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl" mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\" go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$env:VERSION" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop @@ -407,13 +407,17 @@ jobs: overwrite: true build-snap: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [get-release] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + - name: Install Snap host build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev - name: Setup LXD uses: canonical/setup-lxd@v0.1.1 with: @@ -477,6 +481,8 @@ jobs: \$CRAFT_PART_INSTALL/usr/bin/flutter doctor build-packages: - clang + - llvm-18 + - lld - cmake - curl - libgtk-3-dev @@ -505,6 +511,9 @@ jobs: set +e dart pub get set -eux + llvm_bin_dir="\$(dirname "\$(readlink -f "\$(command -v clang)")")" + test -x "\$llvm_bin_dir/llvm-ar" + test -x "\$llvm_bin_dir/ld.lld" flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap" --dart-define="GA4_MEASUREMENT_ID=$GA4_MEASUREMENT_ID" --dart-define="GA4_API_SECRET=$GA4_API_SECRET" mkdir -p \$CRAFT_PART_INSTALL/bin/ cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/ @@ -687,12 +696,15 @@ jobs: VERSION: ${{ needs.get-release.outputs.version }} run: | sudo apt update -y - sudo apt install -y pv bsdmainutils - wget -O qdk2_0.32.bionic_amd64.deb "https://github.com/qnap-dev/qdk2/releases/download/v0.32/qdk2_0.32.bionic_amd64.deb" - dpkg -X qdk2_0.32.bionic_amd64.deb qdk2 # Direct installs will fail due to missing dependencies! - [[ -d qdk2 ]] || exit 1 + qdk_version=2.5.3 + qdk_package="qdk_${qdk_version}_amd64.deb" + curl --fail --location --retry 3 --retry-all-errors \ + --output "${qdk_package}" \ + "https://github.com/qnap-dev/QDK/releases/download/v${qdk_version}/${qdk_package}" + echo "17b3841b7d4590a4ee025844ba583304b5e3c497d9fa8934d5175131d3908022 ${qdk_package}" | sha256sum --check --strict + sudo apt-get install -y "./${qdk_package}" + command -v qbuild - export PATH=$(pwd)/qdk2/usr/bin:$(pwd)/qdk2/usr/share/qdk2/QDK/bin:${PATH} wget -O Gopeed.template.tar.gz "https://github.com/GopeedLab/QpkgBuild/raw/refs/heads/master/template/Gopeed.template.tar.gz" tar -zxf Gopeed.template.tar.gz [[ -d Gopeed ]] || exit 1 diff --git a/.github/workflows/l10n.yml b/.github/workflows/l10n.yml new file mode 100644 index 00000000..7fb8e181 --- /dev/null +++ b/.github/workflows/l10n.yml @@ -0,0 +1,50 @@ +name: l10n + +on: + pull_request: + branches: + - main + paths: + - "ui/flutter/lib/l10n/**" + - "ui/flutter/l10n.yaml" + - ".github/workflows/scripts/check_l10n.dart" + - ".github/workflows/l10n.yml" + push: + branches: + - main + paths: + - "ui/flutter/lib/l10n/**" + - "ui/flutter/l10n.yaml" + - ".github/workflows/scripts/check_l10n.dart" + - ".github/workflows/l10n.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: l10n-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + FLUTTER_VERSION: "3.41.6" + +jobs: + validate: + name: Validate Flutter translations + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui/flutter + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + - run: flutter pub get + - name: Validate ARB catalogs + run: dart run ../../.github/workflows/scripts/check_l10n.dart + - name: Generate localizations + run: flutter gen-l10n + - name: Analyze generated localization integration + run: flutter analyze diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8f673a9..7b9d58a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ on: - "Dockerfile" env: - GO_VERSION: "1.25" + GO_VERSION: "1.25.4" permissions: contents: write diff --git a/.github/workflows/scripts/check_l10n.dart b/.github/workflows/scripts/check_l10n.dart new file mode 100644 index 00000000..a542a51f --- /dev/null +++ b/.github/workflows/scripts/check_l10n.dart @@ -0,0 +1,104 @@ +// Validate Flutter ARB translation catalogs before localization code generation. +// +// Usage (from ui/flutter): +// dart run ../../.github/workflows/scripts/check_l10n.dart +// +// What it checks: +// - Every ARB file uses the repository's canonical two-space JSON formatting. +// - Every app_.arb file declares the matching @@locale value. +// - Message values are not empty. +// - Every locale has exactly the same message keys as app_en.arb. +// - Every translation preserves the placeholder variables used by app_en.arb. +// +// Notes: +// - This script does not judge translation quality. +// - It does not generate localization code or fully parse ICU MessageFormat. +// `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax +// and generate app_localizations*.dart. + +import 'dart:convert'; +import 'dart:io'; + +/// Runs repository-specific checks that `flutter gen-l10n` does not enforce. +void main() { + final directory = Directory('lib/l10n'); + final templateFile = File('${directory.path}/app_en.arb'); + final template = _readArb(templateFile); + final templateKeys = _messageKeys(template); + var failed = false; + + for (final file in directory.listSync().whereType().where( + (file) => file.path.endsWith('.arb'), + )) { + final messages = _readArb(file); + if (!_hasCanonicalFormat(file, messages)) { + stderr.writeln( + '${file.path}: ARB formatting is not canonical (use two-space JSON indentation and a trailing newline)', + ); + failed = true; + } + final expectedLocale = file.uri.pathSegments.last + .replaceFirst('app_', '') + .replaceFirst('.arb', ''); + if (messages['@@locale'] != expectedLocale) { + stderr.writeln('${file.path}: @@locale must be $expectedLocale'); + failed = true; + } + final keys = _messageKeys(messages); + final empty = + keys.where((key) => (messages[key] as String).trim().isEmpty).toList() + ..sort(); + if (empty.isNotEmpty) { + stderr.writeln('${file.path}: empty messages $empty'); + failed = true; + } + if (file.path == templateFile.path) continue; + final missing = templateKeys.difference(keys); + final extra = keys.difference(templateKeys); + if (missing.isNotEmpty || extra.isNotEmpty) { + stderr.writeln( + '${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}', + ); + failed = true; + } + + for (final key in templateKeys.intersection(keys)) { + final expected = _placeholders(template[key] as String); + final actual = _placeholders(messages[key] as String); + if (!_sameSet(expected, actual)) { + stderr.writeln( + '${file.path}: $key placeholders $actual do not match $expected', + ); + failed = true; + } + } + } + + if (failed) exitCode = 1; +} + +/// Reads one ARB file as its top-level JSON object. +Map _readArb(File file) => + jsonDecode(file.readAsStringSync()) as Map; + +/// Checks the exact JSON layout so whitespace-only formatting drift fails CI. +bool _hasCanonicalFormat(File file, Map arb) { + const encoder = JsonEncoder.withIndent(' '); + return file.readAsStringSync() == '${encoder.convert(arb)}\n'; +} + +/// Returns user-facing message keys and excludes ARB metadata keys (`@`/`@@`). +Set _messageKeys(Map arb) => + arb.keys.where((key) => !key.startsWith('@')).toSet(); + +/// Extracts both simple placeholders (`{name}`) and ICU selector variables. +/// +/// For example, this returns `count` from +/// `{count, plural, =1{1 file} other{{count} files}}`. +Set _placeholders(String message) => RegExp( + r'\{([A-Za-z_][A-Za-z0-9_]*)(?=\s*(?:\}|,\s*(?:plural|select|selectordinal)\s*,))', +).allMatches(message).map((match) => match.group(1)!).toSet(); + +/// Compares sets without depending on iteration order. +bool _sameSet(Set left, Set right) => + left.length == right.length && left.containsAll(right); diff --git a/.github/workflows/scripts/flutter_local_font.dart b/.github/workflows/scripts/flutter_local_font.dart index d0c8b59c..822e2c14 100644 --- a/.github/workflows/scripts/flutter_local_font.dart +++ b/.github/workflows/scripts/flutter_local_font.dart @@ -10,7 +10,7 @@ // (including the common pattern where Flutter concatenates the base URL with a relative path) // - Download them into: build/web/assets/gstatic/ // - Rewrite "https://fonts.gstatic.com/s/" -> "assets/gstatic/" so runtime loads locally -// - Only download font subsets needed by supported locales (parsed from message.dart) +// - Only download font subsets needed by supported locales (parsed from lib/l10n/*.arb) // // Notes: // - This script DOES NOT call `flutter build web`. It only patches the output. @@ -23,15 +23,18 @@ import 'dart:io'; /// Uses language code prefix to automatically detect the script system. /// Returns empty set if base fonts (Latin/Cyrillic/Greek) are sufficient. Set _getFontFamiliesForLocale(String locale) { - final lang = locale.toLowerCase().split('_').first; + final normalizedLocale = locale.toLowerCase().replaceAll('-', '_'); + final lang = normalizedLocale.split('_').first; // CJK languages - need specific large font files if (lang == 'zh') { // Simplified vs Traditional Chinese - if (locale.contains('cn') || locale.contains('sg')) { - return {'notosanssc'}; // Simplified Chinese + if (normalizedLocale.contains('_tw') || + normalizedLocale.contains('_hk') || + normalizedLocale.contains('_mo')) { + return {'notosanstc', 'notosanshk'}; // Traditional Chinese (TW, HK, MO) } - return {'notosanstc', 'notosanshk'}; // Traditional Chinese (TW, HK, MO) + return {'notosanssc'}; // Simplified Chinese, including generic zh } if (lang == 'ja') return {'notosansjp'}; // Japanese if (lang == 'ko') return {'notosanskr'}; // Korean @@ -95,29 +98,25 @@ const _baseFontFamilies = { // to reduce bundle size. Add them back if emoji support is needed. }; -/// Parse supported locales from message.dart file. -/// Reads the import statements and extracts locale codes like 'zh_cn', 'en_us', etc. -Set _parseSupportedLocales(File messageFile) { +/// Parse supported locales from Flutter gen-l10n ARB filenames. +/// Filenames follow app_.arb, for example app_zh_CN.arb or app_en.arb. +Set _parseSupportedLocales(Directory l10nDirectory) { final locales = {}; - if (!messageFile.existsSync()) { - _fail('Warning: message.dart not found, only base fonts will be used'); + if (!l10nDirectory.existsSync()) { + _fail('Localization directory not found: ${l10nDirectory.path}'); } - final content = messageFile.readAsStringSync(); - - // Match import statements like: import 'langs/zh_cn.dart'; - final importRegex = RegExp(r"import\s+'langs/(\w+)\.dart'"); - for (final match in importRegex.allMatches(content)) { - final locale = match.group(1); - if (locale != null) { - locales.add(locale); - } + final arbName = RegExp(r'^app_(.+)\.arb$'); + for (final entity in l10nDirectory.listSync()) { + if (entity is! File) continue; + final filename = entity.uri.pathSegments.last; + final locale = arbName.firstMatch(filename)?.group(1); + if (locale != null && locale.isNotEmpty) locales.add(locale); } if (locales.isEmpty) { - _fail( - 'Warning: No locales found in message.dart, only base fonts will be used'); + _fail('No app_.arb files found in ${l10nDirectory.path}'); } return locales; @@ -139,7 +138,9 @@ Set _getRequiredFontFamilies(Set locales) { /// Font paths have the format: "fontfamily/version/filename.ext" /// Example: "notosanssc/v36/xxx.ttf" -> font family is "notosanssc" bool _fontMatchesRequiredFamilies( - String fontPath, Set requiredFamilies) { + String fontPath, + Set requiredFamilies, +) { // Extract font family from path (first segment before '/') final slashIndex = fontPath.indexOf('/'); if (slashIndex == -1) { @@ -158,10 +159,9 @@ Future main(List args) async { // but will also try to locate pubspec.yaml by walking up. final flutterDir = _findFlutterProjectRoot(Directory.current); - // Parse supported locales from message.dart - final messageFile = - File(_join(flutterDir.path, 'lib', 'i18n', 'message.dart')); - final supportedLocales = _parseSupportedLocales(messageFile); + // Parse supported locales from Flutter gen-l10n ARB files. + final l10nDirectory = Directory(_join(flutterDir.path, 'lib', 'l10n')); + final supportedLocales = _parseSupportedLocales(l10nDirectory); stdout.writeln('Supported locales: ${supportedLocales.join(', ')}'); // Get required font families based on supported locales @@ -203,7 +203,8 @@ Future main(List args) async { if (p.contains('://') || p.startsWith('data:')) continue; if (p.startsWith('/') || p.startsWith('assets/') || - p.startsWith('packages/')) continue; + p.startsWith('packages/')) + continue; if (p.contains('..')) continue; relAssetsUnderS.add(p); } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 583c98fe..dda4fa5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ on: workflow_dispatch: env: - GO_VERSION: "1.25" + GO_VERSION: "1.25.4" FLUTTER_VERSION: "3.41.6" jobs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1770c6f3..f65e2a92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,11 +17,60 @@ start the front-end flutter project in `debug` mode to run. ## Translation -The internationalization files of Gopeed are located in the `ui/flutter/lib/i18n/langs` directory. -You only need to add the corresponding language file in this directory. +Flutter translations are ARB files in `ui/flutter/lib/l10n`. Use `app_en.arb` as the source template, +then edit or add `app_.arb` (for example, `app_de.arb` or `app_zh_TW.arb`). Every locale must +contain the same message keys as the English template. -Please refer to `en_us.dart` for translation. -words prefixed with `@` are not meant to be translated. +Translate message values only, and do not rename variables inside braces such as `{count}` and `{name}`. +Do not translate metadata keys prefixed with `@`. Submit only the ARB source files you changed; do not generate or commit +`app_localizations*.dart`. The pull-request CI automatically validates the catalogs, generates the +localization code, and checks its integration. + +The following examples show both the ARB definition and the text users see after the app supplies values. + +1. Insert a variable into a message + + ARB definition: + + ```json + "welcomeUser": "Hola, {name}" + ``` + + Rendered result: when `name = Ana`, users see “Hola, Ana”. You may reorder the sentence, but keep `{name}`. + +2. Display different text for different quantities + + ARB definition: + + ```json + "fileCount": "{count, plural, =0{No hay archivos} =1{1 archivo} other{{count} archivos}}" + ``` + + Rendered results: + + - `count = 0` → “No hay archivos” + - `count = 1` → “1 archivo” + - `count = 3` → “3 archivos” + + `=0`, `=1`, and `other` mean zero, one, and every other quantity. Translate only the user-visible text + inside each branch. + +3. Display different text for different values + + ARB definition: + + ```json + "taskState": "{state, select, running{En curso} paused{En pausa} other{Desconocido}}" + ``` + + Rendered results: + + - `state = running` → “En curso” + - `state = paused` → “En pausa” + - Any other value → “Desconocido” + + `running`, `paused`, and `other` are values supplied by the app. Do not translate them; translate only + the display text inside the following braces. ## flutter development @@ -31,4 +80,4 @@ Turn on build_runner watcher if you want to edit api/models: ``` flutter pub run build_runner watch -``` \ No newline at end of file +``` diff --git a/CONTRIBUTING_ja-JP.md b/CONTRIBUTING_ja-JP.md index 85f27591..8f48dd50 100644 --- a/CONTRIBUTING_ja-JP.md +++ b/CONTRIBUTING_ja-JP.md @@ -17,10 +17,58 @@ ## 翻訳 -Gopeed の国際化ファイルは `ui/flutter/lib/i18n/langs` ディレクトリにあります。 -このディレクトリに対応する言語ファイルを追加するだけでよいです。 +Flutter の翻訳ファイルは `ui/flutter/lib/l10n` にあります。`app_en.arb` を原文テンプレートとして、 +`app_.arb`(例: `app_de.arb`、`app_zh_TW.arb`)を編集または追加してください。すべての言語で +英語テンプレートと同じメッセージ key が必要です。 -翻訳については `en_us.dart` を参照してください。 +翻訳するのはメッセージ値だけです。`{count}` や `{name}` など、中括弧内の変数名は変更しないでください。 +`@` で始まるメタデータ key は翻訳しません。変更した ARB ソースファイルだけをコミットし、 +`app_localizations*.dart` は生成もコミットも不要です。PR の CI が全言語を検証し、国際化コードを生成して +統合結果を確認します。 + +以下の例では、ARB に記述する定義と、アプリが値を渡した後にユーザーが実際に見る表示を示します。 + +1. メッセージに変数を挿入する + + ARB 定義: + + ```json + "welcomeUser": "こんにちは、{name}" + ``` + + 最終表示:`name = 太郎` の場合は「こんにちは、太郎」と表示されます。語順は変更できますが、`{name}` は残します。 + +2. 数量に応じて表示を切り替える + + ARB 定義: + + ```json + "fileCount": "{count, plural, =0{ファイルなし} =1{1 個のファイル} other{{count} 個のファイル}}" + ``` + + 最終表示: + + - `count = 0` →「ファイルなし」 + - `count = 1` →「1 個のファイル」 + - `count = 3` →「3 個のファイル」 + + `=0`、`=1`、`other` はそれぞれ 0、1、その他の数量を表します。各分岐の中で画面に表示される文字だけを翻訳します。 + +3. 変数の値に応じて表示を切り替える + + ARB 定義: + + ```json + "taskState": "{state, select, running{実行中} paused{一時停止} other{不明}}" + ``` + + 最終表示: + + - `state = running` →「実行中」 + - `state = paused` →「一時停止」 + - その他の値 →「不明」 + + `running`、`paused`、`other` はアプリが渡す分岐値なので翻訳せず、その後の中括弧内にある表示文字だけを翻訳します。 ## flutter での開発 diff --git a/CONTRIBUTING_vi-VN.md b/CONTRIBUTING_vi-VN.md index 9948be9d..efbd2d01 100644 --- a/CONTRIBUTING_vi-VN.md +++ b/CONTRIBUTING_vi-VN.md @@ -17,10 +17,60 @@ khởi động dự án flutter frontend trong chế độ `debug` để chạy. ## Dịch thuật -Các tệp quốc tế hóa của Gopeed được đặt trong thư mục `ui/flutter/lib/i18n/langs`. -Bạn chỉ cần thêm tệp ngôn ngữ tương ứng trong thư mục này. +Các bản dịch Flutter là tệp ARB trong `ui/flutter/lib/l10n`. Hãy dùng `app_en.arb` làm mẫu nguồn, +sau đó sửa hoặc thêm `app_.arb` (ví dụ `app_de.arb` hoặc `app_zh_TW.arb`). Mỗi ngôn ngữ phải +có cùng các khóa thông điệp như mẫu tiếng Anh. -Vui lòng tham khảo `en_us.dart` để biết cách dịch thuật. +Chỉ dịch giá trị thông điệp; không đổi tên biến trong dấu ngoặc nhọn như `{count}` và `{name}`. Không dịch khóa +siêu dữ liệu bắt đầu bằng `@`. Chỉ commit các tệp nguồn ARB đã thay đổi; không cần tạo hoặc commit +`app_localizations*.dart`. CI của pull request sẽ tự động +kiểm tra tất cả ngôn ngữ, tạo mã bản địa hóa và xác minh việc tích hợp. + +Các ví dụ sau hiển thị cả định nghĩa trong ARB và nội dung người dùng thực sự thấy sau khi ứng dụng truyền giá trị. + +1. Chèn biến vào thông điệp + + Định nghĩa ARB: + + ```json + "welcomeUser": "Xin chào, {name}" + ``` + + Kết quả hiển thị: khi `name = An`, người dùng thấy “Xin chào, An”. Có thể đổi trật tự câu nhưng phải giữ `{name}`. + +2. Hiển thị nội dung khác nhau theo số lượng + + Định nghĩa ARB: + + ```json + "fileCount": "{count, plural, =0{Không có tệp} =1{1 tệp} other{{count} tệp}}" + ``` + + Kết quả hiển thị: + + - `count = 0` → “Không có tệp” + - `count = 1` → “1 tệp” + - `count = 3` → “3 tệp” + + `=0`, `=1` và `other` lần lượt biểu thị số lượng bằng 0, bằng 1 và các số lượng còn lại. Chỉ dịch phần chữ + hiển thị cho người dùng bên trong mỗi nhánh. + +3. Hiển thị nội dung khác nhau theo giá trị biến + + Định nghĩa ARB: + + ```json + "taskState": "{state, select, running{Đang chạy} paused{Đã tạm dừng} other{Không xác định}}" + ``` + + Kết quả hiển thị: + + - `state = running` → “Đang chạy” + - `state = paused` → “Đã tạm dừng” + - Giá trị khác → “Không xác định” + + `running`, `paused` và `other` là các giá trị do ứng dụng truyền vào nên không được dịch; chỉ dịch phần chữ + hiển thị trong dấu ngoặc theo sau chúng. ## Phát triển flutter diff --git a/CONTRIBUTING_zh-CN.md b/CONTRIBUTING_zh-CN.md index bbc17c71..55137ef5 100644 --- a/CONTRIBUTING_zh-CN.md +++ b/CONTRIBUTING_zh-CN.md @@ -13,10 +13,57 @@ PR,合并到 `main` 分支。 flutter 项目即可运行。 ## 翻译 - -Gopeed 的国际化文件位于 `ui/flutter/lib/i18n/langs` 目录下,只需要在该目录下添加对应的语言文件即可。 -请注意以 `en_us.dart` 为参照进行翻译。 +Flutter 国际化文件位于 `ui/flutter/lib/l10n`,请以 `app_en.arb` 为源模板,修改或新增 +`app_.arb`(例如 `app_de.arb` 或 `app_zh_TW.arb`)。每个语种必须与英文模板包含相同的文案 key。 + +只翻译文案值,不要修改 `{count}`、`{name}` 等花括号中的变量名。以 `@` 开头的元数据 key 不需要翻译。 +提交时只包含修改过的 ARB 源文件,不需要生成或提交 `app_localizations*.dart`。PR 的 CI 会自动校验所有语种、 +生成国际化代码并检查集成结果。 + +下面的例子同时给出 ARB 中填写的文案定义,以及程序传值后用户最终看到的内容。 + +1. 在文案中插入变量 + + ARB 定义: + + ```json + "welcomeUser": "你好,{name}" + ``` + + 最终显示:当 `name = 小明` 时,显示“你好,小明”。翻译时可以调整整句话,但必须保留 `{name}`。 + +2. 根据数量显示不同文案 + + ARB 定义: + + ```json + "fileCount": "{count, plural, =0{没有文件} =1{1 个文件} other{{count} 个文件}}" + ``` + + 最终显示: + + - `count = 0` → “没有文件” + - `count = 1` → “1 个文件” + - `count = 3` → “3 个文件” + + `=0`、`=1`、`other` 分别表示数量为 0、数量为 1 和其他数量。只翻译每个分支花括号内显示给用户的文字。 + +3. 根据变量值显示不同文案 + + ARB 定义: + + ```json + "taskState": "{state, select, running{运行中} paused{已暂停} other{未知状态}}" + ``` + + 最终显示: + + - `state = running` → “运行中” + - `state = paused` → “已暂停” + - 其他值 → “未知状态” + + `running`、`paused`、`other` 是程序传入的分支值,不能翻译;只翻译它们后面花括号内的显示文字。 ## flutter开发 @@ -27,4 +74,3 @@ Gopeed 的国际化文件位于 `ui/flutter/lib/i18n/langs` 目录下,只需 ``` flutter pub run build_runner watch ``` - diff --git a/CONTRIBUTING_zh-TW.md b/CONTRIBUTING_zh-TW.md index 4abfe10f..2b5365f9 100644 --- a/CONTRIBUTING_zh-TW.md +++ b/CONTRIBUTING_zh-TW.md @@ -11,11 +11,57 @@ 建議使用 web 端進行開發與調試,首先啟動服務,使用指令 `go run cmd/api/main.go` 啟動 ,該服務默認連接埠為 `9999`,接著以 `debug` 模式啟動前端 flutter 項目即可。 ## 翻譯 - -Gopeed 的翻譯文件位於 `ui/flutter/lib/i18n/langs` 目錄中,只需要修改或新建翻譯文件即可。 +Flutter 翻譯檔位於 `ui/flutter/lib/l10n`,請以 `app_en.arb` 為來源範本,修改或新增 +`app_.arb`(例如 `app_de.arb` 或 `app_zh_TW.arb`)。每個語言都必須與英文範本包含相同的訊息 key。 -請以 `en_us.dart` 作為參照。 +只翻譯訊息值,不要修改 `{count}`、`{name}` 等大括號中的變數名稱。以 `@` 開頭的中繼資料 key 不需要翻譯。 +提交時只需包含修改過的 ARB 來源檔,不需要產生或提交 `app_localizations*.dart`。PR 的 CI +會自動驗證所有語言、產生國際化程式碼並檢查整合結果。 + +以下範例同時列出 ARB 中填寫的訊息定義,以及程式傳值後使用者最終看到的內容。 + +1. 在訊息中插入變數 + + ARB 定義: + + ```json + "welcomeUser": "你好,{name}" + ``` + + 最終顯示:當 `name = 小明` 時,顯示「你好,小明」。翻譯時可以調整整句話,但必須保留 `{name}`。 + +2. 依數量顯示不同訊息 + + ARB 定義: + + ```json + "fileCount": "{count, plural, =0{沒有檔案} =1{1 個檔案} other{{count} 個檔案}}" + ``` + + 最終顯示: + + - `count = 0` →「沒有檔案」 + - `count = 1` →「1 個檔案」 + - `count = 3` →「3 個檔案」 + + `=0`、`=1`、`other` 分別代表數量為 0、數量為 1 和其他數量。只翻譯各分支大括號內顯示給使用者的文字。 + +3. 依變數值顯示不同訊息 + + ARB 定義: + + ```json + "taskState": "{state, select, running{執行中} paused{已暫停} other{未知狀態}}" + ``` + + 最終顯示: + + - `state = running` →「執行中」 + - `state = paused` →「已暫停」 + - 其他值 →「未知狀態」 + + `running`、`paused`、`other` 是程式傳入的分支值,不能翻譯;只翻譯其後大括號內的顯示文字。 ## flutter開發 diff --git a/README.md b/README.md index e0b7fdfd..8b021002 100644 --- a/README.md +++ b/README.md @@ -10,166 +10,52 @@ GopeedLab%2Fgopeed | Trendshift -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) - [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 Introduction -Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension). +Gopeed (short for **Go Speed**) is a fast, modern, free, and open-source download manager built with Go and Flutter. It supports HTTP, HTTPS, BitTorrent, magnet links, and ed2k on desktop, mobile, and the web. -Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs) +Beyond core download management, Gopeed offers browser integration, JavaScript extensions, a REST API, a CLI, and a self-hosted web UI for customization and automation. + +Visit ✈ [Official Website](https://gopeed.com) + +![Application screenshot](_docs/img/ui-concept-en.png) + +## ✨ Features + +- ⚡ **High-speed downloads** — combine concurrent tasks, multi-connection HTTP transfers, and peer-to-peer BitTorrent downloads to make the most of your bandwidth. +- 🧲 **Multiple protocols** — download HTTP/HTTPS files, torrents, magnet links, and ed2k resources from a single app. +- 🌱 **Full-featured BitTorrent** — use DHT peer discovery, uTP transport, Web Seeds, selective file downloads, tracker management, peer and piece statistics, and ratio- or time-based seeding limits. +- 📋 **Flexible task management** — pause, resume, retry, run batch operations, search, filter by status, organize with categories, and recover tasks after a restart. +- 🪶 **Lightweight native experience** — the main interface is rendered natively with Flutter. No Electron. No WebView shell. Enjoy a smaller footprint, lower overhead, and responsive performance. +- 💻 **Cross-platform** — available for Windows, macOS, Linux, Android, iOS, and the web, with Docker and QNAP deployment options. +- 🎨 **Customizable appearance** — follow your system theme or choose light or dark mode, with eight accent colors. +- 📐 **Responsive interface** — task lists, navigation, settings, and detail views adapt to phones, tablets, and resizable desktop windows. +- 🗣️ **Available in 20+ languages** — including English, Simplified and Traditional Chinese, Japanese, Korean, and many more. +- 🌐 **Browser integration** — send downloads from Chrome, Edge, Firefox, and other compatible browsers directly to Gopeed. +- 🧩 **JavaScript extensions** — add support for video platforms, AI model hubs, cloud storage services, and other download sources. +- 🔌 **Automation-ready** — integrate with Gopeed through its REST API, CLI, authenticated web UI, webhooks, and post-download scripts. +- 🛠️ **Built-in essentials** — customize headers and the User-Agent, use proxies and GitHub mirrors, receive notifications, and extract archives automatically. ## ⬇️ Download - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
🪟 WindowsEXEamd64📥
arm64📥
Portableamd64📥
arm64📥
🍎 MacOSDMGuniversal📥
amd64📥
arm64📥
🐧 LinuxFlathubamd64📥
SNAPamd64📥
DEBamd64📥
arm64📥
AppImageamd64📥
arm64📥
🤖 AndroidAPKuniversal📥
armeabi-v7a📥
arm64-v8a📥
x86_64📥
📱 iOSIPAuniversal📥
🐳 Docker-universal📥
💾 QnapQPKGamd64📥
arm64📥
🌐 WebWindowsamd64📥
arm64📥
386📥
MacOSamd64📥
arm64📥
Linuxamd64📥
arm64📥
386📥
+### 🧪 Gopeed 2.0.0 Beta -More about installation, please refer to [Installation](https://gopeed.com/docs/install) +Gopeed 2.0.0 is currently in public beta. It introduces the new 2.0.0 experience, but some features may still be incomplete or unstable. Please try it and report any issues you encounter. -### 🛠️ Command tool +- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) -use `go install`: +Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel. + +### Stable release + +- [Official Download](https://gopeed.com) +- [GitHub Releases](https://github.com/GopeedLab/gopeed/releases/latest) + +### 🛠️ Command-line tool + +Install the CLI with `go install`: ```bash go install github.com/GopeedLab/gopeed/cmd/gopeed@latest @@ -177,32 +63,28 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest ## 🔌 Browser Extension -Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension) +Use the Gopeed browser extension to send downloads from Chrome, Edge, Firefox, and other compatible browsers directly to Gopeed: [GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension) ## 📱 WeChat Official Account -Follow our WeChat Official Account to get the latest updates and news. +Follow Gopeed's official WeChat account for updates and news. ## 💝 Donate -If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you! - -## 🖼️ Showcase - -![](_docs/img/ui-demo.png) +If Gopeed is useful to you, please consider [supporting its development](https://gopeed.com/docs/donate). Thank you! ## 👨‍💻 Development -This project is divided into two parts, the front end uses `flutter`, the back end uses `Golang`, and the two sides communicate through the `http` protocol. On the unix system, `unix socket` is used, and on the windows system, `tcp` protocol is used. +Gopeed consists of a Flutter front end and a Go back end. They communicate over HTTP, using Unix sockets on Unix-like systems and TCP on Windows. -> The front code is located in the `ui/flutter` directory. +> The front-end source is located in the `ui/flutter` directory. ### 🌍 Environment -1. Golang 1.25+ -2. Flutter 3.38+ +1. Go 1.25+ +2. Flutter 3.41+ ### 📋 Clone @@ -212,17 +94,17 @@ git clone git@github.com:GopeedLab/gopeed.git ### 🤝 Contributing -Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md) +See [CONTRIBUTING.md](/CONTRIBUTING.md). ### 🏗️ Build #### Desktop -First, you need to configure the environment according to the official [Flutter desktop website documention](https://docs.flutter.dev/development/platform-integration/desktop), then you will need to ensure the cgo environment is set up accordingly. For detailed instructions on setting up the cgo environment, please refer to relevant resources available online. +Set up Flutter desktop development using the official [Flutter desktop guide](https://docs.flutter.dev/development/platform-integration/desktop), and make sure a working C toolchain is available for cgo. Then run the commands for your platform. -command: +Commands: -- windows +- Windows ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop @@ -230,7 +112,7 @@ cd ui/flutter flutter build windows ``` -- macos +- macOS ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop @@ -238,7 +120,7 @@ cd ui/flutter flutter build macos ``` -- linux +- Linux ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop @@ -248,7 +130,7 @@ flutter build linux #### Mobile -Same as before, you also need to prepare the `cgo` environment, and then install `gomobile`: +Mobile builds also require a working cgo toolchain. Install and initialize `gomobile`: ```bash go install golang.org/x/mobile/cmd/gomobile@latest @@ -256,9 +138,9 @@ go get golang.org/x/mobile/bind gomobile init ``` -command: +Commands: -- android +- Android ```bash gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile @@ -266,7 +148,7 @@ cd ui/flutter flutter build apk ``` -- ios +- iOS ```bash gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile @@ -276,7 +158,7 @@ flutter build ios --no-codesign #### Web -command: +Build the web app and server: ```bash cd ui/flutter diff --git a/README_ja-JP.md b/README_ja-JP.md index ae3caad1..a8276799 100644 --- a/README_ja-JP.md +++ b/README_ja-JP.md @@ -10,153 +10,48 @@ GopeedLab%2Fgopeed | Trendshift -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) - [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 はじめに -Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。 +Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速でモダンな無料オープンソース・ダウンロードマネージャーです。HTTP、HTTPS、BitTorrent、Magnet、ed2k に対応し、デスクトップ、モバイル、Web で利用できます。 -見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs) +ブラウザー連携、JavaScript 拡張、REST API、CLI、セルフホスト可能な Web UI により、日常利用から高度な自動化まで幅広く対応します。 + +✈ [公式サイト](https://gopeed.com) + +![アプリのスクリーンショット](_docs/img/ui-concept-en.png) + +## ✨ 主な機能 + +- ⚡ **高速ダウンロード** — 複数タスク、HTTP のマルチ接続分割転送、BitTorrent の P2P ダウンロードで帯域を最大限に活用します。 +- 🧲 **マルチプロトコル** — HTTP、HTTPS、BitTorrent、Magnet、ed2k を一つの画面で管理します。 +- 🌱 **充実した BT 機能** — DHT によるピア検出、uTP 転送、Web Seed、ファイル選択、Tracker 管理、Peer/ピース統計、共有率または時間によるシード制御。 +- 📋 **タスク管理** — 一時停止、再開、再試行、一括操作、検索、状態フィルター、カテゴリ、起動時復元。 +- 🪶 **軽量なネイティブ体験** — メイン UI は Flutter でネイティブ描画。Electron 非採用。WebView ラッパーでもありません。パッケージサイズと実行時オーバーヘッドを抑え、軽快な応答性を実現します。 +- 💻 **クロスプラットフォーム** — Windows、macOS、Linux、Android、iOS、Web、Docker、QNAP に対応。 +- 🎨 **カスタマイズ可能なテーマ** — システム連動、ライト、ダークの各モードと 8 色のアクセントカラーに対応。 +- 📐 **レスポンシブ UI** — タスクリスト、ナビゲーション、設定、詳細画面がスマートフォン、タブレット、サイズ変更可能なデスクトップウィンドウに適応。 +- 🗣️ **20 以上の UI 言語** — 日本語、英語、簡体字・繁体字中国語、韓国語など多くの言語に対応。 +- 🌐 **ブラウザー連携** — Chrome、Edge、Firefox などのダウンロードを Gopeed に送信できます。 +- 🧩 **JavaScript 拡張** — 動画サイト、AI モデルハブ、クラウドストレージなどのダウンロード元を追加できます。 +- 🔌 **オープンなインターフェース** — REST API、CLI、認証付き Web UI、Webhook、完了後スクリプトに対応。 +- 🛠️ **便利な内蔵機能** — カスタム Header/User-Agent、Proxy、GitHub ミラー、通知、自動展開。 ## ⬇️ インストール - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
🪟 WindowsEXEamd64📥
Portableamd64📥
🍎 MacOSDMGuniversal📥
amd64📥
arm64📥
🐧 LinuxFlathubamd64📥
SNAPamd64📥
DEBamd64📥
arm64📥
AppImageamd64📥
arm64📥
🤖 AndroidAPKuniversal📥
armeabi-v7a📥
arm64-v8a📥
x86_64📥
📱 iOSIPAuniversal📥
🐳 Docker-universal📥
💾 QnapQPKGamd64📥
arm64📥
🌐 WebWindowsamd64📥
arm64📥
386📥
MacOSamd64📥
arm64📥
Linuxamd64📥
arm64📥
386📥
-インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。 +### 🧪 Gopeed 2.0.0 Beta + +Gopeed 2.0.0 は現在パブリックベータ版です。新しい 2.0.0 のユーザー体験を先行してお試しいただけますが、一部の機能はまだ不完全または不安定な場合があります。ぜひお試しいただき、問題があればフィードバックをお寄せください。 + +- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) + +機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。 + +### 安定版 + +- [公式サイトからダウンロード](https://gopeed.com) +- [GitHub Releases](https://github.com/GopeedLab/gopeed/releases/latest) ### 🛠️ コマンドツール @@ -170,10 +65,6 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください! -## 🖼️ ショーケース - -![](_docs/img/ui-demo.png) - ## 👨‍💻 開発 このプロジェクトは二つの部分に分かれており、フロントエンドでは `flutter` を、バックエンドでは `Golang` を使用し、両者は `http` プロトコルで通信する。ユニックスシステムでは `unix socket` を、ウィンドウズシステムでは `tcp` プロトコルを使用します。 @@ -183,7 +74,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された ### 🌍 環境 1. Go 言語 1.25+ -2. Flutter 3.38+ +2. Flutter 3.41+ ### 📋 クローン diff --git a/README_vi-VN.md b/README_vi-VN.md index 80096816..b3c1786f 100644 --- a/README_vi-VN.md +++ b/README_vi-VN.md @@ -10,153 +10,48 @@ GopeedLab%2Fgopeed | Trendshift -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) - [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 Giới thiệu -Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension). +Gopeed (viết tắt của **Go Speed**) là trình quản lý tải xuống nhanh, hiện đại, miễn phí và mã nguồn mở, được xây dựng bằng Go và Flutter. Gopeed hỗ trợ HTTP, HTTPS, BitTorrent, Magnet và ed2k trên máy tính, thiết bị di động và Web. -Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs) +Ngoài việc quản lý tải xuống hằng ngày, Gopeed còn hỗ trợ tích hợp trình duyệt, tiện ích JavaScript, REST API, CLI và Web UI có thể tự lưu trữ để mở rộng và tự động hóa quy trình. + +Truy cập ✈ [Trang web chính thức](https://gopeed.com) + +![Ảnh chụp màn hình ứng dụng](_docs/img/ui-concept-en.png) + +## ✨ Tính năng chính + +- ⚡ **Tải xuống tốc độ cao** — kết hợp nhiều tác vụ, truyền HTTP phân đoạn qua nhiều kết nối và tải BitTorrent P2P để tận dụng tối đa băng thông. +- 🧲 **Đa giao thức** — quản lý HTTP, HTTPS, BitTorrent, Magnet và ed2k trong một giao diện. +- 🌱 **Bộ công cụ BT đầy đủ** — hỗ trợ khám phá Peer qua DHT, truyền tải uTP, Web Seed, chọn tệp, quản lý Tracker, xem Peer/mảnh và kiểm soát seed theo tỷ lệ hoặc thời gian. +- 📋 **Quản lý tác vụ thực tế** — tạm dừng, tiếp tục, thử lại, thao tác hàng loạt, tìm kiếm, bộ lọc, danh mục và khôi phục khi khởi động. +- 🪶 **Trải nghiệm native gọn nhẹ** — giao diện chính được Flutter kết xuất native. Không Electron. Không vỏ bọc WebView. Kích thước nhỏ hơn, ít tốn tài nguyên hơn và phản hồi nhanh hơn. +- 💻 **Đa nền tảng** — Windows, macOS, Linux, Android, iOS, Web, Docker và QNAP. +- 🎨 **Giao diện tùy biến** — hỗ trợ chế độ theo hệ thống, sáng, tối và 8 màu nhấn. +- 📐 **Bố cục thích ứng** — danh sách tác vụ, điều hướng, cài đặt và màn hình chi tiết tự điều chỉnh cho điện thoại, máy tính bảng và cửa sổ desktop có thể thay đổi kích thước. +- 🗣️ **Hơn 20 ngôn ngữ giao diện** — gồm tiếng Việt, tiếng Anh, tiếng Trung giản thể và phồn thể, tiếng Nhật, tiếng Hàn cùng nhiều ngôn ngữ khác. +- 🌐 **Tích hợp trình duyệt** — gửi tải xuống từ Chrome, Edge, Firefox và các trình duyệt tương thích sang Gopeed. +- 🧩 **Tiện ích JavaScript** — thêm nguồn tải từ nền tảng video, kho mô hình AI, lưu trữ đám mây và nhiều dịch vụ khác. +- 🔌 **Giao diện mở** — REST API, CLI, Web UI có xác thực, webhook và script sau khi tải. +- 🛠️ **Công cụ tích hợp** — Header/User-Agent tùy chỉnh, proxy, mirror GitHub, thông báo và tự động giải nén. ## ⬇️ Tải về - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
🪟 WindowsEXEamd64📥
Portableamd64📥
🍎 MacOSDMGuniversal📥
amd64📥
arm64📥
🐧 LinuxFlathubamd64📥
SNAPamd64📥
DEBamd64📥
arm64📥
AppImageamd64📥
arm64📥
🤖 AndroidAPKuniversal📥
armeabi-v7a📥
arm64-v8a📥
x86_64📥
📱 iOSIPAuniversal📥
🐳 Docker-universal📥
💾 QnapQPKGamd64📥
arm64📥
🌐 WebWindowsamd64📥
arm64📥
386📥
MacOSamd64📥
arm64📥
Linuxamd64📥
arm64📥
386📥
-Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install) +### 🧪 Gopeed 2.0.0 Beta + +Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai. Phiên bản này mang đến trải nghiệm 2.0.0 mới, nhưng một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định. Hãy dùng thử và gửi phản hồi cho chúng tôi nếu bạn gặp vấn đề. + +- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) + +Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta. + +### Bản ổn định + +- [Tải xuống từ trang web chính thức](https://gopeed.com) +- [GitHub Releases](https://github.com/GopeedLab/gopeed/releases/latest) ### 🛠️ Công cụ lệnh @@ -176,10 +71,6 @@ Theo dõi tài khoản chính thức để nhận các cập nhật và tin tứ Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn! -## 🖼️ Trưng bày - -![](_docs/img/ui-demo.png) - ## 👨‍💻 Development Dự án này được chia thành hai phần, phần giao diện sử dụng `flutter`, phần backend sử dụng `Golang`, và hai phía giao tiếp thông qua giao thức `http`. Trên hệ thống unix, sử dụng `unix socket`, và trên hệ thống windows, sử dụng giao thức `tcp`. @@ -189,7 +80,7 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f ### 🌍 Environment 1. Golang 1.25+ -2. Flutter 3.38+ +2. Flutter 3.41+ ### 📋 Clone diff --git a/README_zh-CN.md b/README_zh-CN.md index 92ed80ef..e314ed78 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -10,153 +10,48 @@ GopeedLab%2Fgopeed | Trendshift -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) - [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 介绍 -Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。 +Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go 和 Flutter 开发的高速、现代、免费开源下载器,支持 HTTP、HTTPS、BitTorrent、磁力链接和 ed2k,并覆盖桌面端、移动端与 Web。 -访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs) +除了日常下载任务管理,Gopeed 还提供浏览器接管、JavaScript 扩展、REST API、命令行工具和可自托管 Web UI,方便高级用户扩展和自动化自己的下载流程。 + +访问 ✈ [官方网站](https://gopeed.com) + +![应用截图](_docs/img/ui-concept-zh-CN.png) + +## ✨ 主要特性 + +- ⚡ **高速下载** — 多任务并发、HTTP 多连接分段传输和 BitTorrent P2P 下载,充分利用可用带宽。 +- 🧲 **多协议支持** — 在同一个界面管理 HTTP、HTTPS、BitTorrent、磁力链接和 ed2k。 +- 🌱 **完整 BT 能力** — 支持 DHT 节点发现、uTP 传输、Web Seed、按文件选择下载、Tracker 管理、Peer/分片统计,以及按分享率或时间控制做种。 +- 📋 **实用任务管理** — 暂停、续传、重试、批量操作、搜索、状态筛选、下载分类和启动恢复。 +- 🪶 **轻量原生体验** — 主界面使用 Flutter 原生渲染,非 Electron,非 WebView 的套壳,安装包更轻量、运行开销更低、响应更流畅。 +- 💻 **全平台覆盖** — 支持 Windows、macOS、Linux、Android、iOS 和 Web,并提供 Docker、QNAP 部署方式。 +- 🎨 **个性化主题** — 支持跟随系统、浅色、深色三种模式,以及 8 种主题强调色。 +- 📐 **响应式界面** — 任务列表、导航、设置和详情视图会针对手机、平板及可调整大小的桌面窗口自动布局。 +- 🗣️ **20+ 种界面语言** — 支持简体中文、繁体中文、英语、日语、韩语等多种语言。 +- 🌐 **浏览器接管** — 支持 Chrome、Edge、Firefox 等兼容浏览器,将下载任务直接发送到 Gopeed。 +- 🧩 **JavaScript 扩展** — 扩展视频网站、AI 模型站、网盘等更多下载源。 +- 🔌 **开放接口** — 通过 REST API、CLI、带身份认证的 Web UI、Webhook 和下载后脚本进行自动化。 +- 🛠️ **常用内置能力** — 自定义 Header 与 User-Agent、代理、GitHub 镜像、通知和自动解压。 ## ⬇️ 下载 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
🪟 WindowsEXEamd64📥
Portableamd64📥
🍎 MacOSDMGuniversal📥
amd64📥
arm64📥
🐧 LinuxFlathubamd64📥
SNAPamd64📥
DEBamd64📥
arm64📥
AppImageamd64📥
arm64📥
🤖 AndroidAPKuniversal📥
armeabi-v7a📥
arm64-v8a📥
x86_64📥
📱 iOSIPAuniversal📥
🐳 Docker-universal📥
💾 QnapQPKGamd64📥
arm64📥
🌐 WebWindowsamd64📥
arm64📥
386📥
MacOSamd64📥
arm64📥
Linuxamd64📥
arm64📥
386📥
-更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install) +### 🧪 Gopeed 2.0.0 Beta + +Gopeed 2.0.0 目前处于公开 Beta 测试阶段,带来了全新的 2.0.0 使用体验,但部分功能仍可能不够完善或稳定。欢迎提前体验,并向我们反馈使用过程中遇到的问题。 + +- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) + +当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。 + +### 正式稳定版 + +- [官方网站下载](https://gopeed.com) +- [GitHub Releases](https://github.com/GopeedLab/gopeed/releases/latest) ### 🛠️ 命令行工具 @@ -180,10 +75,6 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、 如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢! -## 🖼️ 界面展示 - -![](_docs/img/ui-demo.png) - ## 👨‍💻 开发 本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。 @@ -193,7 +84,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、 ### 🌍 环境要求 1. Golang 1.25+ -2. Flutter 3.38+ +2. Flutter 3.41+ ### 📋 克隆项目 diff --git a/README_zh-TW.md b/README_zh-TW.md index 2682cb89..979c0147 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -10,154 +10,48 @@ GopeedLab%2Fgopeed | Trendshift -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) - [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 簡介 -Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。 +Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、現代化、免費開源下載器,支援 HTTP、HTTPS、BitTorrent、磁力連結與 ed2k,並涵蓋桌面、行動裝置及 Web。 -前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs) +除了日常任務管理,Gopeed 也提供瀏覽器整合、JavaScript 擴充、REST API、命令列工具及可自行託管的 Web UI,方便進階使用者擴充與自動化下載流程。 + +瀏覽 ✈ [官方網站](https://gopeed.com) + +![應用程式截圖](_docs/img/ui-concept-en.png) + +## ✨ 主要功能 + +- ⚡ **高速下載** — 多任務並行、HTTP 多連線分段傳輸與 BitTorrent P2P 下載,充分利用可用頻寬。 +- 🧲 **多協定支援** — 在同一介面管理 HTTP、HTTPS、BitTorrent、磁力連結與 ed2k。 +- 🌱 **完整 BT 功能** — 支援 DHT 節點探索、uTP 傳輸、Web Seed、逐檔選擇、Tracker 管理、Peer/分片統計,以及依分享率或時間控制做種。 +- 📋 **實用任務管理** — 暫停、續傳、重試、批次操作、搜尋、狀態篩選、分類及啟動恢復。 +- 🪶 **輕量原生體驗** — 主介面使用 Flutter 原生渲染,非 Electron,非 WebView 套殼,安裝包更輕量、執行開銷更低、操作回應更流暢。 +- 💻 **跨平台** — 支援 Windows、macOS、Linux、Android、iOS、Web、Docker 與 QNAP。 +- 🎨 **個人化主題** — 支援跟隨系統、淺色、深色模式及 8 種主題強調色。 +- 📐 **響應式介面** — 任務列表、導覽、設定與詳細資訊會配合手機、平板及可調整大小的桌面視窗自動配置。 +- 🗣️ **20+ 種介面語言** — 支援繁體中文、簡體中文、英文、日文、韓文等多種語言。 +- 🌐 **瀏覽器整合** — 可接管 Chrome、Edge、Firefox 等相容瀏覽器的下載。 +- 🧩 **JavaScript 擴充** — 新增影音網站、AI 模型平台、雲端儲存等下載來源。 +- 🔌 **開放介面** — 支援 REST API、CLI、認證 Web UI、Webhook 及下載後腳本。 +- 🛠️ **實用內建能力** — 自訂 Header/User-Agent、Proxy、GitHub 鏡像、通知及自動解壓縮。 ## ⬇️ 下載 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
🪟 WindowsEXEamd64📥
Portableamd64📥
🍎 MacOSDMGuniversal📥
amd64📥
arm64📥
🐧 LinuxFlathubamd64📥
SNAPamd64📥
DEBamd64📥
arm64📥
AppImageamd64📥
arm64📥
🤖 AndroidAPKuniversal📥
armeabi-v7a📥
arm64-v8a📥
x86_64📥
📱 iOSIPAuniversal📥
🐳 Docker-universal📥
💾 QnapQPKGamd64📥
arm64📥
🌐 WebWindowsamd64📥
arm64📥
386📥
MacOSamd64📥
arm64📥
Linuxamd64📥
arm64📥
386📥
+### 🧪 Gopeed 2.0.0 Beta -更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install) +Gopeed 2.0.0 目前處於公開 Beta 測試階段,帶來全新的 2.0.0 使用體驗,但部分功能仍可能不夠完善或穩定。歡迎搶先體驗,並向我們回報使用過程中遇到的問題。 + +- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) + +當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。 + +### 正式穩定版 + +- [官方網站下載](https://gopeed.com) +- [GitHub Releases](https://github.com/GopeedLab/gopeed/releases/latest) ### 🛠️ 使用 CLI 安裝 @@ -177,10 +71,6 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest 如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝! -## 🖼️ 軟體介面 - -![](_docs/img/ui-demo.png) - ## 👨‍💻 開發 該項目分為前端與後端,前端使用`flutter`編寫,後端使用`Golang`編寫,兩邊通過`http`協定進行通訊,在 unix 系統下,則使用`unix socket`,在 windows 系統下,則使用`tcp`協定。 @@ -190,7 +80,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest ### 🌍 開發環境 1. Golang 1.25+ -2. Flutter 3.38+ +2. Flutter 3.41+ ### 📋 克隆項目 diff --git a/_docs/img/mobile-ui-en.png b/_docs/img/mobile-ui-en.png new file mode 100644 index 00000000..c1094912 Binary files /dev/null and b/_docs/img/mobile-ui-en.png differ diff --git a/_docs/img/mobile-ui-zh-CN.png b/_docs/img/mobile-ui-zh-CN.png new file mode 100644 index 00000000..a94d75f4 Binary files /dev/null and b/_docs/img/mobile-ui-zh-CN.png differ diff --git a/_docs/img/ui-concept-en.png b/_docs/img/ui-concept-en.png new file mode 100644 index 00000000..9035d95a Binary files /dev/null and b/_docs/img/ui-concept-en.png differ diff --git a/_docs/img/ui-concept-zh-CN.png b/_docs/img/ui-concept-zh-CN.png new file mode 100644 index 00000000..2013da41 Binary files /dev/null and b/_docs/img/ui-concept-zh-CN.png differ diff --git a/cmd/host/main.go b/cmd/host/main.go index 7dcf1359..45de0014 100644 --- a/cmd/host/main.go +++ b/cmd/host/main.go @@ -99,12 +99,10 @@ var apiMap = map[string]func(message *Message) (data any, err error){ return } - silent := false - if v, ok := message.Meta["silent"]; ok { - silent, _ = v.(bool) - } - - if err := wakeup(silent); err != nil { + // Task creation owns its presentation: silent requests stay in the + // background, while non-silent requests open a dedicated create window. + // Always bootstrap the main process hidden so it never flashes first. + if err := wakeup(true); err != nil { return nil, err } diff --git a/design/file-category-icons/README.md b/design/file-category-icons/README.md new file mode 100644 index 00000000..821a8513 --- /dev/null +++ b/design/file-category-icons/README.md @@ -0,0 +1,30 @@ +# 文件分类图标源物料 + +此目录是 Gopeed 文件分类图标的唯一源物料目录,位于 Flutter 工程之外,不得加入 `ui/flutter/pubspec.yaml` 的 `assets` 配置。 + +## 维护限制 + +- 不要绕过 `$gopeed-file-category-icons` 技能直接修改、移动或批量替换这里的 SVG 与 manifest。 +- 需要调整图标时,必须配套使用该技能的安全区、码位兼容、字体生成和多尺寸预览流程。 +- 不要直接编辑生成的 `GopeedIcons.ttf` 或 `gopeed_icons.dart`。 +- 不要复用或重排 manifest 中已有的 codepoint。 + +## 目录内容 + +- `svg/`:24×24 的可编辑 SVG 源文件。 +- `manifest.json`:图标名称、SVG、稳定码位与产物路径的唯一映射。 +- `preview/`:生成的 16/20/24/32px 明暗预览,用于整套视觉审查。 + +## 生成产物 + +- Flutter 字体:`ui/flutter/assets/fonts/GopeedIcons.ttf` +- Flutter 常量:`ui/flutter/lib/core/icons/gopeed_icons.dart` + +所有修改都应遵循: + +```text +使用 $gopeed-file-category-icons 修改 SVG/manifest +→ 重新生成完整 TTF 和 Dart 常量 +→ 检查多尺寸明暗预览 +→ 运行 Flutter 静态分析与测试 +``` diff --git a/design/file-category-icons/manifest.json b/design/file-category-icons/manifest.json new file mode 100644 index 00000000..50c95c9f --- /dev/null +++ b/design/file-category-icons/manifest.json @@ -0,0 +1,130 @@ +{ + "fontFamily": "GopeedIcons", + "fontOutput": "../../ui/flutter/assets/fonts/GopeedIcons.ttf", + "dartOutput": "../../ui/flutter/lib/core/icons/gopeed_icons.dart", + "previewOutput": "preview/gopeed_icons_preview.png", + "dartClass": "GopeedIcons", + "codepointStart": "0xE900", + "unitsPerEm": 1024, + "ascent": 896, + "descent": -128, + "padding": 0, + "version": "1.3.2", + "icons": [ + { + "name": "protocol_bt", + "source": "svg/protocol-bt.svg", + "codepoint": "0xE900" + }, + { + "name": "protocol_ed2k", + "source": "svg/protocol-ed2k.svg", + "codepoint": "0xE901" + }, + { + "name": "folder", + "source": "svg/folder.svg", + "codepoint": "0xE902" + }, + { + "name": "folder_bt", + "source": "svg/folder-bt.svg", + "codepoint": "0xE903" + }, + { + "name": "file", + "source": "svg/file.svg", + "codepoint": "0xE904" + }, + { + "name": "file_installer", + "source": "svg/file-installer.svg", + "codepoint": "0xE905" + }, + { + "name": "file_android", + "source": "svg/file-android.svg", + "codepoint": "0xE906" + }, + { + "name": "file_ios", + "source": "svg/file-ios.svg", + "codepoint": "0xE907" + }, + { + "name": "file_disk_image", + "source": "svg/file-disk-image.svg", + "codepoint": "0xE908" + }, + { + "name": "file_web", + "source": "svg/file-web.svg", + "codepoint": "0xE909" + }, + { + "name": "file_text", + "source": "svg/file-text.svg", + "codepoint": "0xE90A" + }, + { + "name": "file_pdf", + "source": "svg/file-pdf.svg", + "codepoint": "0xE90B" + }, + { + "name": "file_document", + "source": "svg/file-document.svg", + "codepoint": "0xE90C" + }, + { + "name": "file_spreadsheet", + "source": "svg/file-spreadsheet.svg", + "codepoint": "0xE90D" + }, + { + "name": "file_presentation", + "source": "svg/file-presentation.svg", + "codepoint": "0xE90E" + }, + { + "name": "file_archive", + "source": "svg/file-archive.svg", + "codepoint": "0xE90F" + }, + { + "name": "file_image", + "source": "svg/file-image.svg", + "codepoint": "0xE910" + }, + { + "name": "file_audio", + "source": "svg/file-audio.svg", + "codepoint": "0xE911" + }, + { + "name": "file_video", + "source": "svg/file-video.svg", + "codepoint": "0xE912" + }, + { + "name": "file_code", + "source": "svg/file-code.svg", + "codepoint": "0xE913" + }, + { + "name": "file_ebook", + "source": "svg/file-ebook.svg", + "codepoint": "0xE914" + }, + { + "name": "file_font", + "source": "svg/file-font.svg", + "codepoint": "0xE915" + }, + { + "name": "file_database", + "source": "svg/file-database.svg", + "codepoint": "0xE916" + } + ] +} diff --git a/design/file-category-icons/preview/gopeed_icons_preview.png b/design/file-category-icons/preview/gopeed_icons_preview.png new file mode 100644 index 00000000..7312eb51 Binary files /dev/null and b/design/file-category-icons/preview/gopeed_icons_preview.png differ diff --git a/design/file-category-icons/svg/file-android.svg b/design/file-category-icons/svg/file-android.svg new file mode 100644 index 00000000..6116950c --- /dev/null +++ b/design/file-category-icons/svg/file-android.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-archive.svg b/design/file-category-icons/svg/file-archive.svg new file mode 100644 index 00000000..78fd0aed --- /dev/null +++ b/design/file-category-icons/svg/file-archive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-audio.svg b/design/file-category-icons/svg/file-audio.svg new file mode 100644 index 00000000..95488958 --- /dev/null +++ b/design/file-category-icons/svg/file-audio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-code.svg b/design/file-category-icons/svg/file-code.svg new file mode 100644 index 00000000..2da92f33 --- /dev/null +++ b/design/file-category-icons/svg/file-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-database.svg b/design/file-category-icons/svg/file-database.svg new file mode 100644 index 00000000..f47a6607 --- /dev/null +++ b/design/file-category-icons/svg/file-database.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-disk-image.svg b/design/file-category-icons/svg/file-disk-image.svg new file mode 100644 index 00000000..0c9ac19f --- /dev/null +++ b/design/file-category-icons/svg/file-disk-image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-document.svg b/design/file-category-icons/svg/file-document.svg new file mode 100644 index 00000000..c28039c7 --- /dev/null +++ b/design/file-category-icons/svg/file-document.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-ebook.svg b/design/file-category-icons/svg/file-ebook.svg new file mode 100644 index 00000000..37fae903 --- /dev/null +++ b/design/file-category-icons/svg/file-ebook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-font.svg b/design/file-category-icons/svg/file-font.svg new file mode 100644 index 00000000..c4253802 --- /dev/null +++ b/design/file-category-icons/svg/file-font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-image.svg b/design/file-category-icons/svg/file-image.svg new file mode 100644 index 00000000..fc338044 --- /dev/null +++ b/design/file-category-icons/svg/file-image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-installer.svg b/design/file-category-icons/svg/file-installer.svg new file mode 100644 index 00000000..0b69c0fd --- /dev/null +++ b/design/file-category-icons/svg/file-installer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-ios.svg b/design/file-category-icons/svg/file-ios.svg new file mode 100644 index 00000000..037e67d6 --- /dev/null +++ b/design/file-category-icons/svg/file-ios.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-pdf.svg b/design/file-category-icons/svg/file-pdf.svg new file mode 100644 index 00000000..1b117a92 --- /dev/null +++ b/design/file-category-icons/svg/file-pdf.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-presentation.svg b/design/file-category-icons/svg/file-presentation.svg new file mode 100644 index 00000000..d2129c0c --- /dev/null +++ b/design/file-category-icons/svg/file-presentation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-spreadsheet.svg b/design/file-category-icons/svg/file-spreadsheet.svg new file mode 100644 index 00000000..e0024992 --- /dev/null +++ b/design/file-category-icons/svg/file-spreadsheet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-text.svg b/design/file-category-icons/svg/file-text.svg new file mode 100644 index 00000000..06af80fd --- /dev/null +++ b/design/file-category-icons/svg/file-text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-video.svg b/design/file-category-icons/svg/file-video.svg new file mode 100644 index 00000000..70b767d3 --- /dev/null +++ b/design/file-category-icons/svg/file-video.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file-web.svg b/design/file-category-icons/svg/file-web.svg new file mode 100644 index 00000000..e29ba183 --- /dev/null +++ b/design/file-category-icons/svg/file-web.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/file.svg b/design/file-category-icons/svg/file.svg new file mode 100644 index 00000000..19373513 --- /dev/null +++ b/design/file-category-icons/svg/file.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/file-category-icons/svg/folder-bt.svg b/design/file-category-icons/svg/folder-bt.svg new file mode 100644 index 00000000..9649be2f --- /dev/null +++ b/design/file-category-icons/svg/folder-bt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/folder.svg b/design/file-category-icons/svg/folder.svg new file mode 100644 index 00000000..20941c62 --- /dev/null +++ b/design/file-category-icons/svg/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/file-category-icons/svg/protocol-bt.svg b/design/file-category-icons/svg/protocol-bt.svg new file mode 100644 index 00000000..031dd255 --- /dev/null +++ b/design/file-category-icons/svg/protocol-bt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/design/file-category-icons/svg/protocol-ed2k.svg b/design/file-category-icons/svg/protocol-ed2k.svg new file mode 100644 index 00000000..681b5941 --- /dev/null +++ b/design/file-category-icons/svg/protocol-ed2k.svg @@ -0,0 +1,4 @@ + + + + diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 5e946abe..4a1374c3 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -21,8 +21,9 @@ type Fetcher interface { Pause() error Close() error - // Stats refreshes health statistics and returns the latest information - Stats() any + // Stats returns both the durable protocol snapshot and transient runtime + // telemetry. Downloader persists Snapshot and keeps Runtime in memory only. + Stats() *Stats // Meta returns the meta information of the download. Meta() *FetcherMeta // Progress returns the progress of the download. @@ -31,6 +32,14 @@ type Fetcher interface { Wait() error } +// Stats separates protocol information that remains meaningful after a task +// stops from telemetry that is valid only while the fetcher is active. +// Protocols own both payload schemas; Downloader treats them as opaque values. +type Stats struct { + Snapshot any `json:"snapshot"` + Runtime any `json:"runtime"` +} + type Uploader interface { Upload() error UploadedBytes() int64 diff --git a/internal/fetcher/fetcher_test.go b/internal/fetcher/fetcher_test.go index e67f8510..4fb4515d 100644 --- a/internal/fetcher/fetcher_test.go +++ b/internal/fetcher/fetcher_test.go @@ -1,6 +1,23 @@ package fetcher -import "testing" +import ( + "encoding/json" + "testing" +) + +func TestStatsMarshalRawSnapshotWithoutRuntime(t *testing.T) { + stats := &Stats{ + Snapshot: json.RawMessage(`{"value":1}`), + } + + data, err := json.Marshal(stats) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if got, want := string(data), `{"snapshot":{"value":1},"runtime":null}`; got != want { + t.Fatalf("Marshal() = %s, want %s", got, want) + } +} func TestSchemeFilter_Match(t *testing.T) { type fields struct { diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index d9202dd7..46be76c6 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -18,6 +18,7 @@ import ( "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/bt" "github.com/GopeedLab/gopeed/pkg/util" + "github.com/RoaringBitmap/roaring" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/storage" @@ -162,20 +163,209 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta { return f.meta } -func (f *Fetcher) Stats() any { +func (f *Fetcher) Stats() *fetcher.Stats { var stats torrent.TorrentStats + var pieceMap *base.PieceMap + pieceMapReady := false + peers := make([]*base.PeerStats, 0) if f.torrent != nil { stats = f.torrent.Stats() + pieceMap, pieceMapReady = buildBTPieceMap(f.torrent) + peers = buildBTPeers(f.torrent) } else { stats = torrent.TorrentStats{} } - return &bt.Stats{ - TotalPeers: stats.TotalPeers, - ActivePeers: stats.ActivePeers, - ConnectedSeeders: stats.ConnectedSeeders, - SeedBytes: f.data.SeedBytes, - SeedRatio: f.seedRadio(), - SeedTime: f.data.SeedTime, + var snapshot any + if pieceMapReady { + snapshot = &bt.StatsSnapshot{ + SeedBytes: f.data.SeedBytes, + SeedRatio: f.seedRadio(), + SeedTime: f.data.SeedTime, + PieceMap: pieceMap.Clone(), + } + } + // When completion is temporarily unknown while anacrolix restores storage + // or verifies pieces, the nil Snapshot tells Downloader to retain the last + // trusted bitset instead of replacing it with an all-empty map. + return &fetcher.Stats{ + Snapshot: snapshot, + Runtime: &bt.StatsRuntime{ + TotalPeers: stats.TotalPeers, + ActivePeers: stats.ActivePeers, + ConnectedSeeders: stats.ConnectedSeeders, + ConnectedLeechers: stats.ActivePeers - stats.ConnectedSeeders, + Peers: peers, + }, + } +} + +// buildBTPieceMap converts anacrolix's ordered completion runs to Gopeed's +// one-bit map. ready is false until storage knows the completion state of every +// piece; this prevents initial hash checking from briefly replacing a restored +// map with an all-empty value. +func buildBTPieceMap(t *torrent.Torrent) (pieceMap *base.PieceMap, ready bool) { + info := t.Info() + if info == nil { + return nil, false + } + pieceMap = base.NewPieceMap(info.NumPieces(), info.PieceLength) + ready = applyBTPieceRuns(pieceMap, t.PieceStateRuns()) + return pieceMap, ready +} + +func applyBTPieceRuns(pieceMap *base.PieceMap, runs []torrent.PieceStateRun) (ready bool) { + ready = true + pieceIndex := 0 + for _, run := range runs { + if !run.Ok { + ready = false + } + completed := run.Ok && run.Complete + for range run.Length { + if pieceIndex >= pieceMap.PieceCount() { + return ready + } + _ = pieceMap.Update(pieceIndex, completed) + pieceIndex++ + } + } + return ready && pieceIndex == pieceMap.PieceCount() +} + +func buildBTPeers(t *torrent.Torrent) []*base.PeerStats { + connections := t.PeerConns() + totalPieces := 0 + var missingWantedPieces *roaring.Bitmap + // Magnet torrents can connect to peers before their metadata arrives. Keep + // those runtime peer stats, but leave completion and relevance unavailable + // until the metadata provides a trusted total piece count. + if info := t.Info(); info != nil { + totalPieces = info.NumPieces() + missingWantedPieces = btMissingWantedPieces(t.PieceStateRuns(), totalPieces) + } + peers := make([]*base.PeerStats, 0, len(connections)) + for _, connection := range connections { + peerStats := connection.Stats() + peers = append(peers, &base.PeerStats{ + Address: safeBTPeerAddress(connection), + Client: btPeerClient(connection), + DownloadSpeed: int64(peerStats.DownloadRate), + UploadSpeed: int64(peerStats.LastWriteUploadRate), + PieceCount: peerStats.RemotePieceCount, + Completion: btPeerCompletion(peerStats.RemotePieceCount, totalPieces), + Relevance: btPeerRelevance(connection.PeerPieces(), missingWantedPieces), + Source: normalizeBTPeerSource(connection.Discovery), + Transport: normalizeBTTransport(connection.Network), + }) + } + return peers +} + +// btPeerCompletion converts anacrolix's remote piece count into the ratio the +// API exposes. A nil value distinguishes unavailable metadata from a real 0%. +func btPeerCompletion(pieceCount, totalPieces int) *float64 { + if totalPieces <= 0 { + return nil + } + if pieceCount < 0 { + pieceCount = 0 + } else if pieceCount > totalPieces { + pieceCount = totalPieces + } + completion := float64(pieceCount) / float64(totalPieces) + return &completion +} + +// btMissingWantedPieces builds the denominator used by peer relevance. Pieces +// belonging only to skipped files are excluded because their priority is none. +func btMissingWantedPieces(runs []torrent.PieceStateRun, totalPieces int) *roaring.Bitmap { + if totalPieces <= 0 { + return nil + } + missing := roaring.New() + pieceIndex := 0 + for _, run := range runs { + for range run.Length { + if pieceIndex >= totalPieces { + return nil + } + if run.Priority != torrent.PiecePriorityNone && !(run.Ok && run.Complete) { + missing.Add(uint32(pieceIndex)) + } + pieceIndex++ + } + } + if pieceIndex != totalPieces { + return nil + } + return missing +} + +// btPeerRelevance reports how much of the local wanted, missing set the remote +// peer can provide. A zero value is meaningful; nil means there is no denominator. +func btPeerRelevance(peerPieces, missingWantedPieces *roaring.Bitmap) *float64 { + if peerPieces == nil || missingWantedPieces == nil || missingWantedPieces.IsEmpty() { + return nil + } + relevantPieces := roaring.And(peerPieces, missingWantedPieces).GetCardinality() + relevance := float64(relevantPieces) / float64(missingWantedPieces.GetCardinality()) + return &relevance +} + +// A uTP socket may close between PeerConns and this snapshot. Some uTP address +// implementations panic after close, so one disappearing address must not make +// the entire stats endpoint fail. +func safeBTPeerAddress(connection *torrent.PeerConn) (address string) { + defer func() { + if recover() != nil { + address = "" + } + }() + if connection.RemoteAddr == nil { + return "" + } + return connection.RemoteAddr.String() +} + +func btPeerClient(connection *torrent.PeerConn) string { + value := connection.PeerClientName.Load() + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func normalizeBTPeerSource(source torrent.PeerSource) string { + switch source { + case torrent.PeerSourceTracker: + return "tracker" + case torrent.PeerSourceDhtGetPeers, torrent.PeerSourceDhtAnnouncePeer: + return "dht" + case torrent.PeerSourcePex: + return "pex" + case torrent.PeerSourceIncoming: + return "incoming" + case torrent.PeerSourceDirect: + return "direct" + case torrent.PeerSourceUtHolepunch: + return "holepunch" + default: + return "unknown" + } +} + +func normalizeBTTransport(network string) string { + network = strings.ToLower(network) + switch { + case strings.Contains(network, "webrtc"): + return "webrtc" + case strings.Contains(network, "udp"): + // anacrolix labels uTP sockets with their underlying udp network. + return "utp" + case strings.Contains(network, "tcp"): + return "tcp" + default: + return "unknown" } } diff --git a/internal/protocol/bt/stats_test.go b/internal/protocol/bt/stats_test.go new file mode 100644 index 00000000..b6ac8681 --- /dev/null +++ b/internal/protocol/bt/stats_test.go @@ -0,0 +1,199 @@ +package bt + +import ( + "testing" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" + protocolbt "github.com/GopeedLab/gopeed/pkg/protocol/bt" + "github.com/RoaringBitmap/roaring" + "github.com/anacrolix/torrent" + "github.com/anacrolix/torrent/storage" +) + +func TestStatsSeparatesSnapshotAndRuntime(t *testing.T) { + frame := (&Fetcher{ + meta: &fetcher.FetcherMeta{Res: &base.Resource{Size: 256}}, + data: &fetcherData{SeedBytes: 128, SeedTime: 64}, + }).Stats() + if frame.Snapshot != nil { + t.Fatalf("snapshot = %T, want nil until piece completion is known", frame.Snapshot) + } + runtime, ok := frame.Runtime.(*protocolbt.StatsRuntime) + if !ok { + t.Fatalf("runtime type = %T", frame.Runtime) + } + if len(runtime.Peers) != 0 || runtime.TotalPeers != 0 { + t.Fatalf("unexpected runtime: %+v", runtime) + } +} + +func TestStatsBeforeTorrentMetadataIsAvailable(t *testing.T) { + config := torrent.TestingConfig(t) + config.DisableTCP = true + config.DisableUTP = true + client, err := torrent.NewClient(config) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + torrentWithoutMetadata, err := client.AddMagnet( + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + ) + if err != nil { + t.Fatal(err) + } + if info := torrentWithoutMetadata.Info(); info != nil { + t.Fatalf("torrent info = %+v, want nil before metadata is available", info) + } + + frame := (&Fetcher{ + torrent: torrentWithoutMetadata, + meta: &fetcher.FetcherMeta{}, + data: &fetcherData{}, + }).Stats() + if frame.Snapshot != nil { + t.Fatalf("snapshot = %T, want nil before metadata is available", frame.Snapshot) + } + runtime, ok := frame.Runtime.(*protocolbt.StatsRuntime) + if !ok { + t.Fatalf("runtime type = %T", frame.Runtime) + } + if len(runtime.Peers) != 0 || runtime.TotalPeers != 0 { + t.Fatalf("unexpected runtime before metadata is available: %+v", runtime) + } +} + +func TestApplyBTPieceRunsPublishesVerifiedCompletionOnly(t *testing.T) { + pieceMap := base.NewPieceMap(4, 1024) + ready := applyBTPieceRuns(pieceMap, []torrent.PieceStateRun{ + {PieceState: torrent.PieceState{Completion: torrentCompletion(true, true)}, Length: 1}, + {PieceState: torrent.PieceState{Completion: torrentCompletion(true, false), Partial: true}, Length: 2}, + {PieceState: torrent.PieceState{Completion: torrentCompletion(false, false), Hashing: true}, Length: 1}, + }) + if ready { + t.Fatal("piece map became ready while one completion is unknown") + } + want := []bool{true, false, false, false} + for index, expected := range want { + got, err := pieceMap.Get(index) + if err != nil || got != expected { + t.Fatalf("piece %d = %v, %v; want %v", index, got, err, expected) + } + } + + knownMap := base.NewPieceMap(2, 1024) + if !applyBTPieceRuns(knownMap, []torrent.PieceStateRun{ + {PieceState: torrent.PieceState{Completion: torrentCompletion(true, true)}, Length: 1}, + {PieceState: torrent.PieceState{Completion: torrentCompletion(true, false)}, Length: 1}, + }) { + t.Fatal("fully known completion map was not ready") + } + if applyBTPieceRuns(base.NewPieceMap(2, 1024), []torrent.PieceStateRun{ + {PieceState: torrent.PieceState{Completion: torrentCompletion(true, true)}, Length: 1}, + }) { + t.Fatal("incomplete piece runs were published") + } +} + +func torrentCompletion(ok, complete bool) storage.Completion { + return storage.Completion{Ok: ok, Complete: complete} +} + +func TestNormalizeBTPeerMetadata(t *testing.T) { + if got := normalizeBTPeerSource(torrent.PeerSourceDhtGetPeers); got != "dht" { + t.Fatalf("DHT source = %q", got) + } + if got := normalizeBTPeerSource(torrent.PeerSourcePex); got != "pex" { + t.Fatalf("PEX source = %q", got) + } + transports := map[string]string{ + "udp4": "utp", + "udp6": "utp", + "tcp4": "tcp", + "tcp6": "tcp", + "webrtc": "webrtc", + "unix": "unknown", + } + for network, want := range transports { + if got := normalizeBTTransport(network); got != want { + t.Errorf("normalizeBTTransport(%q) = %q, want %q", network, got, want) + } + } +} + +func TestBTPeerCompletion(t *testing.T) { + if got := btPeerCompletion(1, 4); got == nil || *got != 0.25 { + t.Fatalf("completion = %v, want 0.25", got) + } + if got := btPeerCompletion(5, 4); got == nil || *got != 1 { + t.Fatalf("clamped completion = %v, want 1", got) + } + if got := btPeerCompletion(-1, 4); got == nil || *got != 0 { + t.Fatalf("negative completion = %v, want 0", got) + } + if got := btPeerCompletion(0, 0); got != nil { + t.Fatalf("completion without metadata = %v, want nil", *got) + } +} + +func TestBTPeerRelevanceUsesWantedMissingPieces(t *testing.T) { + missing := btMissingWantedPieces([]torrent.PieceStateRun{ + { + PieceState: torrent.PieceState{ + Completion: torrentCompletion(true, true), + Priority: torrent.PiecePriorityNormal, + }, + Length: 2, + }, + { + PieceState: torrent.PieceState{ + Completion: torrentCompletion(true, false), + Priority: torrent.PiecePriorityNormal, + }, + Length: 2, + }, + { + PieceState: torrent.PieceState{ + Completion: torrentCompletion(true, false), + Priority: torrent.PiecePriorityNone, + }, + Length: 2, + }, + }, 6) + if missing == nil || !missing.Equals(roaring.BitmapOf(2, 3)) { + t.Fatalf("missing wanted pieces = %v, want {2, 3}", missing) + } + + if got := btPeerRelevance(roaring.BitmapOf(0, 2, 4), missing); got == nil || *got != 0.5 { + t.Fatalf("relevance = %v, want 0.5", got) + } + if got := btPeerRelevance(roaring.BitmapOf(0, 1, 4), missing); got == nil || *got != 0 { + t.Fatalf("zero relevance = %v, want 0", got) + } + if got := btPeerRelevance(roaring.BitmapOf(2, 3), roaring.New()); got != nil { + t.Fatalf("relevance with no missing pieces = %v, want nil", *got) + } + if got := btMissingWantedPieces(nil, 6); got != nil { + t.Fatalf("incomplete piece states = %v, want nil", got) + } +} + +func TestBTPeerRelevanceTracksRemainingDownload(t *testing.T) { + missing := roaring.BitmapOf(90, 91, 92, 93, 94, 95, 96, 97, 98, 99) + if got := btPeerRelevance(missing.Clone(), missing); got == nil || *got != 1 { + t.Fatalf("peer covering every missing piece = %v, want 1", got) + } + + oneRelevantPiece := roaring.BitmapOf(90) + if got := btPeerRelevance(oneRelevantPiece, missing); got == nil || *got != 0.1 { + t.Fatalf("peer covering one of ten missing pieces = %v, want 0.1", got) + } + + missingAfterTransfer := missing.Clone() + missingAfterTransfer.Remove(90) + if got := btPeerRelevance(oneRelevantPiece, missingAfterTransfer); got == nil || *got != 0 { + t.Fatalf("peer after transferring its only relevant piece = %v, want 0", got) + } +} diff --git a/internal/protocol/ed2k/fetcher.go b/internal/protocol/ed2k/fetcher.go index 46c86b62..81da7ef1 100644 --- a/internal/protocol/ed2k/fetcher.go +++ b/internal/protocol/ed2k/fetcher.go @@ -210,23 +210,76 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta { return f.meta } -func (f *Fetcher) Stats() any { +func (f *Fetcher) Stats() *fetcher.Stats { handle := f.currentHandle() if !handle.IsValid() { - return &ped2k.Stats{} + return &fetcher.Stats{ + Runtime: &ped2k.StatsRuntime{ + Peers: make([]*base.PeerStats, 0), + }, + } } status := handle.GetStatus() - return &ped2k.Stats{ - State: string(status.State), - ActivePeers: handle.ActiveConnections(), - TotalPeers: status.NumPeers, - DownloadRate: status.DownloadRate, - Upload: status.Upload, - UploadRate: status.UploadRate, - TotalDone: status.TotalDone, - TotalReceived: status.TotalReceived, - TotalWanted: status.TotalWanted, + pieceMap := buildED2KPieceMap(handle.PieceSnapshots()) + mapReady := pieceMap != nil && status.State != goed2k.LoadingResumeData && status.State != goed2k.Verifying + var snapshot any + if mapReady { + snapshot = &ped2k.StatsSnapshot{ + Upload: status.Upload, + TotalDone: status.TotalDone, + TotalReceived: status.TotalReceived, + TotalWanted: status.TotalWanted, + PieceMap: pieceMap.Clone(), + } } + // Resume loading and verification are transitional. A nil Snapshot retains + // the last trusted completion bitset until goed2k publishes a stable map. + return &fetcher.Stats{ + Snapshot: snapshot, + Runtime: &ped2k.StatsRuntime{ + State: string(status.State), + Paused: status.Paused, + ActivePeers: handle.ActiveConnections(), + TotalPeers: status.NumPeers, + DownloadRate: status.DownloadRate, + UploadRate: status.UploadRate, + Peers: buildED2KPeers(handle.GetPeersInfo()), + }, + } +} + +func buildED2KPieceMap(snapshots []goed2k.PieceSnapshot) *base.PieceMap { + if len(snapshots) == 0 { + return nil + } + pieceMap := base.NewPieceMap(len(snapshots), goed2k.PieceSize) + for _, snapshot := range snapshots { + if snapshot.Index < 0 || snapshot.Index >= pieceMap.PieceCount() { + continue + } + _ = pieceMap.Update(snapshot.Index, snapshot.State == goed2k.PieceSnapshotFinished) + } + return pieceMap +} + +func buildED2KPeers(peerInfos []goed2k.PeerInfo) []*base.PeerStats { + peers := make([]*base.PeerStats, 0, len(peerInfos)) + for _, peerInfo := range peerInfos { + peers = append(peers, &base.PeerStats{ + Address: peerInfo.Endpoint.String(), + Client: ed2kPeerClient(peerInfo), + DownloadSpeed: int64(peerInfo.PayloadDownloadSpeed), + UploadSpeed: int64(peerInfo.PayloadUploadSpeed), + PieceCount: peerInfo.RemotePieces.Count(), + Source: peerInfo.SourceString(), + Transport: "tcp", + }) + } + return peers +} + +func ed2kPeerClient(peerInfo goed2k.PeerInfo) string { + return strings.TrimSpace(peerInfo.ModName + " " + peerInfo.StrModVersion) } func (f *Fetcher) Progress() fetcher.Progress { diff --git a/internal/protocol/ed2k/stats_test.go b/internal/protocol/ed2k/stats_test.go new file mode 100644 index 00000000..880f2c89 --- /dev/null +++ b/internal/protocol/ed2k/stats_test.go @@ -0,0 +1,72 @@ +package ed2k + +import ( + "testing" + + ped2k "github.com/GopeedLab/gopeed/pkg/protocol/ed2k" + "github.com/monkeyWie/goed2k" + "github.com/monkeyWie/goed2k/protocol" +) + +func TestStatsSeparatesSnapshotAndRuntimeWithoutHandle(t *testing.T) { + frame := (&Fetcher{}).Stats() + if frame.Snapshot != nil { + t.Fatalf("snapshot = %T, want nil without a valid handle", frame.Snapshot) + } + runtime, ok := frame.Runtime.(*ped2k.StatsRuntime) + if !ok { + t.Fatalf("runtime type = %T", frame.Runtime) + } + if runtime.Peers == nil || len(runtime.Peers) != 0 { + t.Fatalf("unexpected runtime peers: %+v", runtime.Peers) + } +} + +func TestBuildED2KPieceMapPreservesIndexes(t *testing.T) { + snapshots := []goed2k.PieceSnapshot{ + {Index: 0, State: goed2k.PieceSnapshotFinished}, + {Index: 1, State: goed2k.PieceSnapshotMissing}, + {Index: 2, State: goed2k.PieceSnapshotDownloading}, + {Index: 3, State: goed2k.PieceSnapshotFinished}, + } + pieceMap := buildED2KPieceMap(snapshots) + if pieceMap == nil { + t.Fatal("buildED2KPieceMap returned nil") + } + want := []bool{true, false, false, true} + for index, expected := range want { + got, err := pieceMap.Get(index) + if err != nil || got != expected { + t.Fatalf("piece %d = %v, %v; want %v", index, got, err, expected) + } + } + if pieceMap.CompletedPieces() != 2 { + t.Fatalf("CompletedPieces() = %d, want 2", pieceMap.CompletedPieces()) + } +} + +func TestBuildED2KPeers(t *testing.T) { + remotePieces := protocol.NewBitField(4) + remotePieces.SetBit(0) + remotePieces.SetBit(2) + endpoint, err := protocol.EndpointFromString("127.0.0.1", 4662) + if err != nil { + t.Fatalf("EndpointFromString: %v", err) + } + peers := buildED2KPeers([]goed2k.PeerInfo{{ + PayloadDownloadSpeed: 2048, + PayloadUploadSpeed: 1024, + RemotePieces: remotePieces, + Endpoint: endpoint, + ModName: "aMule", + StrModVersion: "2.3.3", + SourceFlag: int(goed2k.PeerServer | goed2k.PeerDHT), + }}) + if len(peers) != 1 { + t.Fatalf("len(peers) = %d, want 1", len(peers)) + } + peer := peers[0] + if peer.Client != "aMule 2.3.3" || peer.PieceCount != 2 || peer.Source != "server|kad" || peer.Transport != "tcp" { + t.Fatalf("unexpected peer: %+v", peer) + } +} diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 425261b1..cb168cf3 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -857,7 +857,9 @@ func (f *Fetcher) startResolveDownload() { Chunk: newChunk(0, 0), // For non-range, end doesn't matter } conn.ctx, conn.cancel = context.WithCancel(f.ctx) + f.connMu.Lock() f.connections = append(f.connections, conn) + f.connMu.Unlock() f.wg.Add(1) // Use the resolve response directly @@ -1029,7 +1031,9 @@ func (f *Fetcher) runConnection(conn *connection) { buf := make([]byte, 8192) retries := 0 + f.connMu.Lock() conn.retryTimes = 0 + f.connMu.Unlock() for { // Rebuild client with updated fast-fail timeout on retries @@ -1058,7 +1062,9 @@ func (f *Fetcher) runConnection(conn *connection) { // Reset counters after a successful help switch retries = 0 + f.connMu.Lock() conn.retryTimes = 0 + f.connMu.Unlock() continue } @@ -1082,11 +1088,13 @@ func (f *Fetcher) runConnection(conn *connection) { return } + f.connMu.Lock() if re := extractRequestError(err); re != nil { conn.lastErr = re } else { conn.lastErr = err } + f.connMu.Unlock() if shouldCountHTTPFailure(err) { if re := extractRequestError(err); re != nil && re.Code == 403 { @@ -1099,14 +1107,15 @@ func (f *Fetcher) runConnection(conn *connection) { } return } - conn.retryTimes++ f.connMu.Lock() + conn.retryTimes++ + retryTimes := conn.retryTimes conn.failed = true f.connMu.Unlock() if f.slowStart != nil { f.slowStart.onConnectFailed() } - if conn.retryTimes >= 3 { + if retryTimes >= 3 { f.connMu.Lock() conn.State = connFailed f.connMu.Unlock() @@ -1866,11 +1875,13 @@ func (f *Fetcher) runConnectionFallback(conn *connection) { return } + f.connMu.Lock() if re := extractRequestError(err); re != nil { conn.lastErr = re } else { conn.lastErr = err } + f.connMu.Unlock() if shouldCountHTTPFailure(err) { // Immediate fail for server connection limit (403) @@ -1884,7 +1895,9 @@ func (f *Fetcher) runConnectionFallback(conn *connection) { } return } + f.connMu.Lock() conn.retryTimes++ + f.connMu.Unlock() countedRetries++ if countedRetries >= 3 { f.connMu.Lock() @@ -2348,22 +2361,72 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta { return f.meta } -func (f *Fetcher) Stats() any { +func (f *Fetcher) Stats() *fetcher.Stats { f.connMu.Lock() defer f.connMu.Unlock() + terminal := f.getState() == stateDone + prefetched := int64(0) + connectionCount := len(f.connections) + if connectionCount == 0 { + prefetched = f.resolveDataPos.Load() + if terminal && f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > prefetched { + // A small range response can finish entirely during resolve prefetch, + // before a formal connection is created. The fetcher owns this fact, so + // expose one aggregate connection here instead of making Downloader + // infer protocol state from task-level progress. + prefetched = f.meta.Res.Size + } + if prefetched > 0 { + connectionCount = 1 + } + } + connectionTotal := f.averageConnectionTotal(connectionCount) statsConnections := make([]*fhttp.StatsConnection, 0) for _, connection := range f.connections { + completed := connection.Completed || connection.State == connCompleted + // A successful fetcher has no active downloads left. Work stealing and + // completion-by-total-size can leave a helper connection's last internal + // state non-terminal even though the whole HTTP transfer is finished. + if terminal && !connection.failed { + completed = true + } statsConnections = append(statsConnections, &fhttp.StatsConnection{ Downloaded: connection.Downloaded, - Completed: connection.Completed, + Total: connectionTotal, + Completed: completed, Failed: connection.failed, RetryTimes: connection.retryTimes, }) } - return &fhttp.Stats{ - Connections: statsConnections, + if len(statsConnections) == 0 && prefetched > 0 { + statsConnections = append(statsConnections, &fhttp.StatsConnection{ + Downloaded: prefetched, + Total: connectionTotal, + Completed: terminal, + }) } + return &fetcher.Stats{ + Snapshot: &fhttp.Stats{ + Connections: statsConnections, + }, + } +} + +// averageConnectionTotal returns the nominal equal share used to compare how +// much work each currently visible connection has contributed. HTTP slow start +// can add connections over time, so all lanes are recalculated against the +// current connection count whenever stats are requested. A remainder is +// rounded up to avoid reporting 100% before a connection reaches its share. +func (f *Fetcher) averageConnectionTotal(connectionCount int) int64 { + if f.meta == nil || f.meta.Res == nil || f.meta.Res.Size <= 0 || connectionCount <= 0 { + return 0 + } + total := f.meta.Res.Size / int64(connectionCount) + if f.meta.Res.Size%int64(connectionCount) != 0 { + total++ + } + return total } func (f *Fetcher) Progress() fetcher.Progress { diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index c6880b14..7f07bedb 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -443,7 +443,7 @@ func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) { time.Sleep(20 * time.Millisecond) - stats := fetcher.Stats().(*http.Stats) + stats := fetcher.Stats().Snapshot.(*http.Stats) if len(stats.Connections) != 1 { t.Fatalf("expected a single non-range connection, got %d", len(stats.Connections)) } @@ -461,7 +461,7 @@ func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) { t.Fatal(err) } - finalStats := fetcher.Stats().(*http.Stats) + finalStats := fetcher.Stats().Snapshot.(*http.Stats) if len(finalStats.Connections) != 1 { t.Fatalf("expected a single non-range connection after resume, got %d", len(finalStats.Connections)) } @@ -518,7 +518,7 @@ func TestFetcher_DownloadIgnoredRangeFallsBackToSequential(t *testing.T) { if f.meta.Res.Range { t.Fatal("expected ignored Range response to disable range mode") } - stats := f.Stats().(*http.Stats) + stats := f.Stats().Snapshot.(*http.Stats) if len(stats.Connections) != 1 { t.Fatalf("expected one sequential connection, got %d", len(stats.Connections)) } @@ -783,7 +783,7 @@ func TestFetcher_SequentialRetryReprobesRange(t *testing.T) { t.Fatalf("Range mode = %v, want %v", f.meta.Res.Range, tt.wantRangeMode) } if tt.wantParallel { - stats := f.Stats().(*http.Stats) + stats := f.Stats().Snapshot.(*http.Stats) if len(stats.Connections) <= 1 { f.slowStart.mu.Lock() totalLaunched := f.slowStart.totalLaunched @@ -1999,7 +1999,7 @@ func TestFetcher_DownloadResponseBodyReadTimeout(t *testing.T) { t.Fatal(err) } - stats := fetcher.Stats().(*http.Stats) + stats := fetcher.Stats().Snapshot.(*http.Stats) if len(stats.Connections) == 0 { t.Fatalf("expected connections stats for timeout test") } @@ -2045,7 +2045,7 @@ func TestFetcher_Download500Recovery(t *testing.T) { } // Verify 500 errors don't count as failures (retryTimes should be 0) - stats := fetcher.Stats().(*http.Stats) + stats := fetcher.Stats().Snapshot.(*http.Stats) for _, conn := range stats.Connections { if conn.RetryTimes != 0 { t.Errorf("Expected retryTimes to be 0 for 500 errors (exempt), got %d", conn.RetryTimes) @@ -2144,22 +2144,87 @@ func TestFetcher_Stats(t *testing.T) { if err != nil { t.Fatal(err) } - stats := fetcher.Stats().(*http.Stats) + stats := fetcher.Stats().Snapshot.(*http.Stats) // With slow-start strategy, connection count may be less than max if download is fast // Just verify we have at least 1 connection and no more than max if len(stats.Connections) < 1 || len(stats.Connections) > 16 { t.Errorf("Stats() connection count got = %v, want between 1 and 16", len(stats.Connections)) } totalDownloaded := int64(0) + expectedConnectionTotal := test.BuildSize / int64(len(stats.Connections)) + if test.BuildSize%int64(len(stats.Connections)) != 0 { + expectedConnectionTotal++ + } for i, conn := range stats.Connections { t.Logf("Connection %d: Downloaded=%d, Completed=%v", i, conn.Downloaded, conn.Completed) totalDownloaded += conn.Downloaded + if conn.Total != expectedConnectionTotal { + t.Errorf("connection %d total = %d, want shared total %d", i, conn.Total, expectedConnectionTotal) + } } if totalDownloaded != test.BuildSize { t.Errorf("Stats() got = %v, want %v", totalDownloaded, test.BuildSize) } } +func TestFetcher_StatsMarksTerminalNonFailedConnectionsComplete(t *testing.T) { + fetcher := &Fetcher{ + meta: &fetcher.FetcherMeta{Res: &base.Resource{Size: 4096, Range: true}}, + connections: []*connection{ + {State: connDownloading, Chunk: newChunk(0, 2047), Downloaded: 1024}, + {State: connCompleted, Chunk: newChunk(2048, 3071), Downloaded: 2048}, + {State: connFailed, Chunk: newChunk(3072, 4095), Downloaded: 512, failed: true, retryTimes: 3}, + }, + } + fetcher.setState(stateDone) + + stats := fetcher.Stats().Snapshot.(*http.Stats) + if !stats.Connections[0].Completed { + t.Fatal("terminal non-failed connection was reported as downloading") + } + if !stats.Connections[1].Completed { + t.Fatal("completed connection was not reported as completed") + } + if stats.Connections[2].Completed || !stats.Connections[2].Failed { + t.Fatal("failed connection lost its failure state") + } + const averageTotal = int64(1366) // ceil(4096 / 3) + for index, connection := range stats.Connections { + if connection.Total != averageTotal { + t.Fatalf("connection %d total = %d, want shared total %d", index, connection.Total, averageTotal) + } + } +} + +func TestFetcher_StatsUsesUnknownTotalWithoutResourceSize(t *testing.T) { + fetcher := &Fetcher{ + meta: &fetcher.FetcherMeta{Res: &base.Resource{}}, + connections: []*connection{ + {State: connDownloading, Chunk: newChunk(0, 0), Downloaded: 1024}, + }, + } + + stats := fetcher.Stats().Snapshot.(*http.Stats) + if stats.Connections[0].Total != 0 { + t.Fatalf("unknown resource total = %d, want 0", stats.Connections[0].Total) + } +} + +func TestFetcher_StatsExposesCompletedResolvePrefetch(t *testing.T) { + fetcher := &Fetcher{meta: &fetcher.FetcherMeta{Res: &base.Resource{Size: 100}}} + fetcher.resolveDataPos.Store(100) + fetcher.setState(stateDone) + + stats := fetcher.Stats().Snapshot.(*http.Stats) + if len(stats.Connections) != 1 { + t.Fatalf("connection count = %d, want 1", len(stats.Connections)) + } + connection := stats.Connections[0] + if connection.Downloaded != 100 || connection.Total != 100 || !connection.Completed { + t.Fatalf("unexpected prefetch connection: %+v", connection) + } +} + // TestFetcher_DownloadOneTimeURL tests downloading from a URL that can only be accessed once // This simulates signed URLs or one-time download links that expire after first use func TestFetcher_DownloadOneTimeURL(t *testing.T) { @@ -2261,7 +2326,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) { } // Check final connection count equals maxConns exactly - stats := fetcher.Stats().(*http.Stats) + stats := fetcher.Stats().Snapshot.(*http.Stats) finalConns := len(stats.Connections) // Debug: show connection details and metadata @@ -2407,7 +2472,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) { } // Check stats - should have connections that downloaded remaining data - stats := fetcher.Stats().(*http.Stats) + stats := fetcher.Stats().Snapshot.(*http.Stats) t.Logf("Final connections: %d", len(stats.Connections)) prefetchedUsed := fetcher.resolveDataPos.Load() diff --git a/pkg/base/model.go b/pkg/base/model.go index b99d5d53..b77e9928 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -133,6 +133,8 @@ type Options struct { Name string `json:"name"` // Download file path Path string `json:"path"` + // Use this path as the default download directory after creation succeeds + AsDefaultPath bool `json:"asDefaultPath,omitempty"` // Select file indexes to download SelectFiles []int `json:"selectFiles"` // Extra info for specific fetcher @@ -206,6 +208,7 @@ type DownloaderStoreConfig struct { Script *ScriptConfig `json:"script"` // Script is the script execution configuration AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration + AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files } diff --git a/pkg/base/piece_map.go b/pkg/base/piece_map.go new file mode 100644 index 00000000..4f0a801c --- /dev/null +++ b/pkg/base/piece_map.go @@ -0,0 +1,198 @@ +package base + +import ( + "encoding/json" + "errors" + "fmt" + "math/bits" +) + +const ( + // PieceMapEncoding identifies the one-bit wire format used by PieceMap. + // One byte stores eight ordered pieces, least-significant bit first: piece + // 0 uses bit 0, piece 1 uses bit 1, and so on. A set bit means the piece is + // locally complete and verified; a clear bit means it is not complete. + PieceMapEncoding = "bitset-v1" + + piecesPerByte = 8 +) + +var ErrPieceIndexOutOfRange = errors.New("piece index out of range") + +// PieceMap stores the verified completion state of ordered pieces as a bitset. +// It deliberately excludes scheduler states such as downloading, hashing and +// failed: those states are transient and are not stable across pause/restart. +// +// Fields are private so PieceCount, CompletedPieces and packed bytes remain +// consistent. Use Add, Delete, Update and Get for mutation, and Bytes when a +// binary copy is required. JSON serializes Data as Base64. +type PieceMap struct { + pieceCount int + pieceSize int64 + completedPieces int + data []byte +} + +// NewPieceMap creates a map containing pieceCount incomplete pieces. +func NewPieceMap(pieceCount int, pieceSize int64) *PieceMap { + if pieceCount < 0 { + pieceCount = 0 + } + return &PieceMap{ + pieceCount: pieceCount, + pieceSize: pieceSize, + data: make([]byte, packedPieceByteCount(pieceCount)), + } +} + +// NewPieceMapFromBytes restores a bitset-v1 map. Input bytes are copied and +// unused high bits in the last byte are cleared. +func NewPieceMapFromBytes(pieceCount int, pieceSize int64, data []byte) (*PieceMap, error) { + if pieceCount < 0 { + return nil, fmt.Errorf("%w: negative piece count", ErrPieceIndexOutOfRange) + } + expectedBytes := packedPieceByteCount(pieceCount) + if len(data) != expectedBytes { + return nil, fmt.Errorf("invalid piece data length: got %d, want %d", len(data), expectedBytes) + } + + pieceMap := &PieceMap{ + pieceCount: pieceCount, + pieceSize: pieceSize, + data: append([]byte(nil), data...), + } + pieceMap.clearUnusedBits() + for _, value := range pieceMap.data { + pieceMap.completedPieces += bits.OnesCount8(value) + } + return pieceMap, nil +} + +// Clone returns an independent ordered snapshot. +func (m *PieceMap) Clone() *PieceMap { + if m == nil { + return nil + } + return &PieceMap{ + pieceCount: m.pieceCount, + pieceSize: m.pieceSize, + completedPieces: m.completedPieces, + data: append([]byte(nil), m.data...), + } +} + +// Add appends a piece while preserving all existing indexes. +func (m *PieceMap) Add(completed bool) { + if m.pieceCount%piecesPerByte == 0 { + m.data = append(m.data, 0) + } + m.pieceCount++ + if completed { + m.setCompleted(m.pieceCount-1, true) + m.completedPieces++ + } +} + +// Delete removes a piece and shifts every following piece one index left. +func (m *PieceMap) Delete(index int) error { + if !m.validIndex(index) { + return ErrPieceIndexOutOfRange + } + if m.completedAt(index) { + m.completedPieces-- + } + for current := index; current < m.pieceCount-1; current++ { + m.setCompleted(current, m.completedAt(current+1)) + } + m.pieceCount-- + m.data = m.data[:packedPieceByteCount(m.pieceCount)] + m.clearUnusedBits() + return nil +} + +// Update changes whether the piece at index is complete and verified. +func (m *PieceMap) Update(index int, completed bool) error { + if !m.validIndex(index) { + return ErrPieceIndexOutOfRange + } + previous := m.completedAt(index) + if previous == completed { + return nil + } + if completed { + m.completedPieces++ + } else { + m.completedPieces-- + } + m.setCompleted(index, completed) + return nil +} + +// Get reports whether the exact piece index is complete and verified. +func (m *PieceMap) Get(index int) (bool, error) { + if !m.validIndex(index) { + return false, ErrPieceIndexOutOfRange + } + return m.completedAt(index), nil +} + +// Bytes returns an independent copy of the packed bitset-v1 bytes. +func (m *PieceMap) Bytes() []byte { + return append([]byte(nil), m.data...) +} + +func (m *PieceMap) PieceCount() int { return m.pieceCount } +func (m *PieceMap) PieceSize() int64 { return m.pieceSize } +func (m *PieceMap) CompletedPieces() int { return m.completedPieces } + +// MarshalJSON publishes the compact bytes without exposing mutable storage. +func (m PieceMap) MarshalJSON() ([]byte, error) { + type pieceMapJSON struct { + Encoding string `json:"encoding"` + PieceCount int `json:"pieceCount"` + PieceSize int64 `json:"pieceSize"` + CompletedPieces int `json:"completedPieces"` + Data []byte `json:"data"` + } + return json.Marshal(pieceMapJSON{ + Encoding: PieceMapEncoding, + PieceCount: m.pieceCount, + PieceSize: m.pieceSize, + CompletedPieces: m.completedPieces, + Data: m.data, + }) +} + +func (m *PieceMap) validIndex(index int) bool { + return m != nil && index >= 0 && index < m.pieceCount +} + +func (m *PieceMap) completedAt(index int) bool { + byteIndex, mask := piecePosition(index) + return m.data[byteIndex]&mask != 0 +} + +func (m *PieceMap) setCompleted(index int, completed bool) { + byteIndex, mask := piecePosition(index) + if completed { + m.data[byteIndex] |= mask + } else { + m.data[byteIndex] &^= mask + } +} + +func (m *PieceMap) clearUnusedBits() { + if m.pieceCount == 0 || m.pieceCount%piecesPerByte == 0 { + return + } + usedBits := uint(m.pieceCount % piecesPerByte) + m.data[len(m.data)-1] &= byte(1< 0 { - for _, task := range d.tasks { + uploadCheckpointDue := time.Since(lastUploadCheckpoint) >= uploadCheckpointInterval + d.lock.Lock() + tasks := append([]*Task(nil), d.tasks...) + d.lock.Unlock() + if len(tasks) > 0 { + for _, task := range tasks { func() { // Do not acquire d.lock (via GetTask) while holding // statusLock; scheduling uses the opposite lock order. @@ -260,6 +276,7 @@ func (d *Downloader) Setup() error { current := task.fetcher.Progress().TotalDownloaded() tick := float64(d.cfg.RefreshInterval) / 1000 downloadDataChanged := false + uploading := task.Uploading if task.Status == base.DownloadStatusRunning { downloadDataChanged = current != task.Progress.Downloaded task.Progress.Used = task.timer.Used() @@ -268,7 +285,7 @@ func (d *Downloader) Setup() error { } uploadDataChanged := false - if task.Uploading { + if uploading { uploader := task.fetcher.(fetcher.Uploader) currentUploaded := uploader.UploadedBytes() uploadDataChanged = currentUploaded != task.Progress.Uploaded @@ -276,23 +293,44 @@ func (d *Downloader) Setup() error { task.Progress.Uploaded = currentUploaded } task.statusLock.Unlock() + // Stats are pulled on the same cadence as speed so fetchers stay + // storage-agnostic. Runtime is refreshed in memory every tick, while + // only a changed durable Snapshot reaches bucketTaskStats. + if err := d.captureTaskStats(task, true, false); err != nil { + d.Logger.Warn().Err(err).Msgf("persist task stats failed, task id: %s", task.ID) + } // Listener callbacks may Pause/Continue and acquire statusLock. d.emit(EventKeyProgress, task) - // store fetcher progress when download/upload data changed - if !downloadDataChanged && !uploadDataChanged { + // Store active upload state periodically even without traffic so + // protocol-owned values such as BT seed time remain durable. + if !shouldSaveTaskCheckpoint( + downloadDataChanged, + uploadDataChanged, + uploading, + uploadCheckpointDue, + ) { return } - d.saveTask(task) + if err := d.saveTask(task); err != nil { + d.Logger.Warn().Err(err).Msgf("persist task checkpoint failed, task id: %s", task.ID) + } }() } } + if uploadCheckpointDue { + lastUploadCheckpoint = time.Now() + } time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval)) } }() return nil } +func shouldSaveTaskCheckpoint(downloadChanged, uploadChanged, uploading, uploadCheckpointDue bool) bool { + return downloadChanged || uploadChanged || uploading && uploadCheckpointDue +} + // cleanupNonExistingTasks checks for tasks whose files are missing on disk // and removes them if the AutoCleanMissingFiles config is enabled. func (d *Downloader) cleanupNonExistingTasks() { @@ -493,6 +531,18 @@ func (d *Downloader) remainRunningCount() int { } func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) { + defaultPath, err := d.prepareDefaultPath(opts) + if err != nil { + return "", err + } + taskId, err = d.createDirect(req, opts) + if err == nil { + d.persistDefaultPathBestEffort(defaultPath) + } + return +} + +func (d *Downloader) createDirect(req *base.Request, opts *base.Options) (taskId string, err error) { ensureRequestRawURL(req) var fetcher fetcher.Fetcher fetcher, err = d.buildFetcher(req.URL) @@ -508,18 +558,26 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId } func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) { + defaultPath, err := d.prepareDefaultPath(req.Opts) + if err != nil { + return nil, err + } taskIds := make([]string, 0) for _, ir := range req.Reqs { opts := ir.Opts if opts == nil { opts = req.Opts } - taskId, err := d.CreateDirect(ir.Req, opts.Clone()) + if opts != nil { + opts = opts.Clone() + } + taskId, err := d.createDirect(ir.Req, opts) if err != nil { return nil, err } taskIds = append(taskIds, taskId) } + d.persistDefaultPathBestEffort(defaultPath) return taskIds, nil } @@ -535,7 +593,15 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) { delete(d.fetcherCache, rrId) d.fetcherMapLock.Unlock() }() - return d.doCreate(fetcher, nil) + defaultPath, err := d.prepareDefaultPath(fetcher.Meta().Opts) + if err != nil { + return "", err + } + taskId, err = d.doCreate(fetcher, nil) + if err == nil { + d.persistDefaultPathBestEffort(defaultPath) + } + return } // Patch modifies task-specific data based on the protocol. @@ -738,6 +804,16 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) { return } +// ContinueOnStartup applies the persisted startup policy after the backend is +// ready to accept connections. Continue(nil) intentionally preserves the same +// continue-all semantics exposed by the API, including retrying failed tasks. +func (d *Downloader) ContinueOnStartup() error { + if !d.cfg.DownloaderStoreConfig.AutoStartTasks { + return nil + } + return d.Continue(nil) +} + func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) { if filter == nil || filter.IsEmpty() { return d.deleteAll(force) @@ -811,19 +887,189 @@ func (d *Downloader) Stats(id string) (sr any, err error) { if task == nil { return sr, ErrTaskNotFound } - if task.fetcher == nil { - err = func() error { - task.statusLock.Lock() - defer task.statusLock.Unlock() - return d.restoreFetcher(task) - }() - if err != nil { - return + // A loaded fetcher is authoritative. Query it directly without restoring a + // paused fetcher from bucketSave: statistics reads must never consume or + // otherwise mutate download recovery state. + task.lock.Lock() + if task.fetcher != nil { + status := d.taskStatus(task) + live := status == base.DownloadStatusRunning || task.Uploading + if captureErr := d.captureTaskStats(task, live, false); captureErr != nil { + d.Logger.Warn().Err(captureErr).Msgf("capture task stats failed, task id: %s", task.ID) } } - sr = task.fetcher.Stats() - return + task.lock.Unlock() + + stats, err := d.loadTaskStats(task) + if err != nil || stats == nil { + return nil, err + } + return stats, nil +} + +type storedTaskStats struct { + Snapshot json.RawMessage `json:"snapshot"` +} + +// captureTaskStats updates the task-level cache from a loaded fetcher and +// persists only the stable Snapshot payload. Runtime telemetry is deliberately +// discarded as soon as a task is no longer active. +func (d *Downloader) captureTaskStats(task *Task, live, force bool) error { + if task == nil || task.fetcher == nil { + return nil + } + stats := task.fetcher.Stats() + if stats == nil { + return nil + } + if !live { + stats = &fetcher.Stats{Snapshot: stats.Snapshot} + } + + hasSnapshotUpdate := stats.Snapshot != nil + var snapshot json.RawMessage + if hasSnapshotUpdate { + encoded, err := json.Marshal(stats.Snapshot) + if err != nil { + return err + } + if !bytes.Equal(encoded, []byte("null")) { + snapshot = encoded + } else { + hasSnapshotUpdate = false + } + } + + task.statsLock.Lock() + defer task.statsLock.Unlock() + if err := d.loadStoredTaskStatsLocked(task); err != nil { + return err + } + if !hasSnapshotUpdate { + // A nil Snapshot means the protocol is temporarily unable to provide a + // trustworthy stable view (for example while BT verifies restored + // storage). Keep the previous Snapshot and refresh Runtime only. + var previousSnapshot any + if task.stats != nil { + previousSnapshot = task.stats.Snapshot + } + if previousSnapshot == nil && len(task.statsSnapshot) > 0 { + previousSnapshot = append(json.RawMessage(nil), task.statsSnapshot...) + } + if previousSnapshot == nil && stats.Runtime == nil { + task.stats = nil + } else { + task.stats = &fetcher.Stats{Snapshot: previousSnapshot, Runtime: stats.Runtime} + } + return nil + } + + task.stats = stats + if !force && bytes.Equal(snapshot, task.statsSnapshot) { + return nil + } + + if err := d.storage.Put(bucketTaskStats, task.ID, &storedTaskStats{Snapshot: snapshot}); err != nil { + return err + } + task.statsSnapshot = append(task.statsSnapshot[:0], snapshot...) + return nil +} + +// loadTaskStats lazily restores a task snapshot. The protocol payload stays as +// json.RawMessage so Downloader remains independent from HTTP, BT, and ED2K +// schemas and can return it without a second decode/encode cycle. +func (d *Downloader) loadTaskStats(task *Task) (*fetcher.Stats, error) { + task.statsLock.Lock() + defer task.statsLock.Unlock() + if err := d.loadStoredTaskStatsLocked(task); err != nil { + return nil, err + } + return task.stats, nil +} + +// loadStoredTaskStatsLocked initializes the cache from storage once. Caller +// must hold statsLock. It is shared by reads and nil-Snapshot refreshes so the +// latter can retain a trusted snapshot after process restart. +func (d *Downloader) loadStoredTaskStatsLocked(task *Task) error { + if task.statsLoaded { + return nil + } + var stored storedTaskStats + exists, err := d.storage.Get(bucketTaskStats, task.ID, &stored) + if err != nil { + return err + } + task.statsLoaded = true + if !exists || len(stored.Snapshot) == 0 { + return nil + } + task.statsSnapshot = append(json.RawMessage(nil), stored.Snapshot...) + task.stats = &fetcher.Stats{ + Snapshot: append(json.RawMessage(nil), stored.Snapshot...), + } + return nil +} + +// RuntimeStatus returns the small task snapshot used by high-frequency detail +// polling. Reading task.lock before statusLock follows the same lock order as +// pause and patch operations, while also keeping fetcher and resource metadata +// stable while file progress is collected. +func (d *Downloader) RuntimeStatus(id string) (*TaskRuntimeStatus, error) { + task := d.GetTask(id) + if task == nil { + return nil, ErrTaskNotFound + } + + task.lock.Lock() + defer task.lock.Unlock() + task.statusLock.Lock() + defer task.statusLock.Unlock() + + result := &TaskRuntimeStatus{ + Status: task.Status, + Files: make([]FileRuntimeStatus, 0), + } + if task.Progress != nil { + result.Used = task.Progress.Used + result.Speed = task.Progress.Speed + result.Downloaded = task.Progress.Downloaded + result.UploadSpeed = task.Progress.UploadSpeed + result.Uploaded = task.Progress.Uploaded + result.ExtractStatus = task.Progress.ExtractStatus + result.ExtractProgress = task.Progress.ExtractProgress + } + if task.Meta == nil || task.Meta.Res == nil { + return result, nil + } + + result.Total = task.Meta.Res.Size + if task.fetcher == nil { + return result, nil + } + + fileProgress := task.fetcher.Progress() + var selectedFiles []int + if task.Meta.Opts != nil { + selectedFiles = task.Meta.Opts.SelectFiles + } + resourceFiles := task.Meta.Res.Files + for progressIndex, downloaded := range fileProgress { + resourceIndex := progressIndex + if progressIndex < len(selectedFiles) { + resourceIndex = selectedFiles[progressIndex] + } + if resourceIndex < 0 || resourceIndex >= len(resourceFiles) { + continue + } + result.Files = append(result.Files, FileRuntimeStatus{ + Index: resourceIndex, + Size: resourceFiles[resourceIndex].Size, + Downloaded: downloaded, + }) + } + return result, nil } func (d *Downloader) doDelete(task *Task, force bool) (err error) { @@ -835,6 +1081,9 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) { if err := d.storage.Delete(bucketSave, task.ID); err != nil { return err } + if err := d.storage.Delete(bucketTaskStats, task.ID); err != nil { + return err + } if task.fetcher != nil { if err := task.fetcher.Close(); err != nil { @@ -1012,12 +1261,60 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task { } func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) { + d.configLock.RLock() + defer d.configLock.RUnlock() return d.cfg.DownloaderStoreConfig, nil } func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error { - d.cfg.DownloaderStoreConfig = v - return d.storage.Put(bucketConfig, "config", v) + if v == nil { + return errors.New("config is nil") + } + next := util.DeepClone(v) + d.configLock.Lock() + defer d.configLock.Unlock() + if err := d.storage.Put(bucketConfig, "config", next); err != nil { + return err + } + d.cfg.DownloaderStoreConfig = next + return nil +} + +func (d *Downloader) prepareDefaultPath(opts *base.Options) (string, error) { + if opts == nil || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" { + return "", nil + } + selectedPath := strings.TrimSpace(opts.Path) + selectedPath, err := d.initDownloadPath(selectedPath) + if err != nil { + return "", err + } + return selectedPath, nil +} + +func (d *Downloader) persistDefaultPathBestEffort(defaultPath string) { + if defaultPath == "" { + return + } + if err := d.persistDefaultPath(defaultPath); err != nil { + d.Logger.Warn().Err(err).Msg("persist default download path failed") + } +} + +func (d *Downloader) persistDefaultPath(defaultPath string) error { + d.configLock.Lock() + defer d.configLock.Unlock() + + next := util.DeepClone(d.cfg.DownloaderStoreConfig) + if next.DownloadDir == defaultPath { + return nil + } + next.DownloadDir = defaultPath + if err := d.storage.Put(bucketConfig, "config", next); err != nil { + return err + } + d.cfg.DownloaderStoreConfig = next + return nil } func (d *Downloader) getProtocolConfig(name string, v any) bool { @@ -1116,6 +1413,9 @@ func (d *Downloader) watch(task *Task) { if !d.markTaskDone(task) { return } + if err := d.captureTaskStats(task, false, true); err != nil { + d.Logger.Error().Stack().Err(err).Msgf("persist completed task stats failed, task id: %s", task.ID) + } d.storage.Put(bucketTask, task.ID, task.clone()) d.emit(EventKeyDone, task) d.emit(EventKeyFinally, task, err) @@ -1228,6 +1528,14 @@ func (d *Downloader) handleOnError(task *Task, err error, resetFetcher bool) { d.Logger.Warn().Err(resetErr).Msgf("reset recovered task fetcher failed, task id: %s", task.ID) } } + if statsErr := d.captureTaskStats(task, false, true); statsErr != nil { + d.Logger.Warn().Err(statsErr).Msgf("persist failed task stats failed, task id: %s", task.ID) + } + if d.taskStatus(task) == base.DownloadStatusError { + if persistErr := d.storage.Put(bucketTask, task.ID, task.clone()); persistErr != nil { + d.Logger.Error().Stack().Err(persistErr).Msgf("persist failed task failed, task id: %s", task.ID) + } + } task.lock.Unlock() if d.taskStatus(task) == base.DownloadStatusError { d.releaseBlobTask(task) @@ -1266,6 +1574,14 @@ func (d *Downloader) resetTaskFetcher(task *Task) error { task.Progress.Downloaded = 0 task.Progress.Speed = 0 } + task.statsLock.Lock() + task.statsLoaded = true + task.stats = nil + task.statsSnapshot = nil + task.statsLock.Unlock() + if err := d.storage.Delete(bucketTaskStats, task.ID); err != nil { + return err + } return d.storage.Delete(bucketSave, task.ID) } @@ -1281,7 +1597,7 @@ func (d *Downloader) restoreTask(task *Task) error { func (d *Downloader) restoreFetcher(task *Task) error { v, f := task.fetcherManager.Restore() if v != nil { - err := d.storage.Pop(bucketSave, task.ID, v) + _, err := d.storage.Get(bucketSave, task.ID, v) if err != nil { return err } @@ -1367,23 +1683,32 @@ func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) { } opts.Path = storeConfig.DownloadDir } + path, err := d.initDownloadPath(opts.Path) + if err != nil { + return nil, err + } + opts.Path = path + return opts, nil +} + +func (d *Downloader) initDownloadPath(path string) (string, error) { // Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%) - opts.Path = util.ReplacePathPlaceholders(opts.Path) + path = util.ReplacePathPlaceholders(path) // if enable white download directory, check if the download directory is in the white list if len(d.cfg.WhiteDownloadDirs) > 0 { inWhiteList := false for _, dir := range d.cfg.WhiteDownloadDirs { - if match, err := filepath.Match(dir, opts.Path); match && err == nil { + if match, err := filepath.Match(dir, path); match && err == nil { inWhiteList = true break } } if !inWhiteList { - return nil, errors.New("download directory is not in white list") + return "", errors.New("download directory is not in white list") } } - return opts, nil + return path, nil } func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) { @@ -1492,6 +1817,11 @@ func (d *Downloader) doStart(task *Task) (err error) { return err } started = true + if err := d.captureTaskStats(task, true, true); err != nil { + // Statistics are auxiliary. A storage or serialization failure must not + // turn an already-started download into a failed task. + d.Logger.Warn().Err(err).Msgf("persist started task stats failed, task id: %s", task.ID) + } if err := d.saveTask(task); err != nil { return err } @@ -1553,7 +1883,7 @@ func (d *Downloader) doPauseForScheduling(task *Task) (bool, error) { func (d *Downloader) preparePause(task *Task) (generation uint64, isReturn bool, err error) { isReturn, err = d.statusMut(task, func() (isReturn bool, err error) { - if task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusDone { + if task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusDone || task.Status == base.DownloadStatusError { isReturn = true return } @@ -1582,6 +1912,13 @@ func (d *Downloader) runPauseHandler(task *Task, generation uint64, pauseFetcher return false, err } } + if task.fetcher != nil { + if err := d.captureTaskStats(task, false, true); err != nil { + // The fetcher is already paused at this point. Keep the task lifecycle + // successful even if its optional statistics snapshot cannot be stored. + d.Logger.Warn().Err(err).Msgf("persist paused task stats failed, task id: %s", task.ID) + } + } if task.fetcherManager != nil && task.fetcher != nil { if err := d.saveTask(task); err != nil { return false, err @@ -2063,6 +2400,7 @@ func initTask(task *Task) { task.statusLock = &sync.Mutex{} task.lock = &sync.Mutex{} + task.statsLock = &sync.Mutex{} task.blobRefLock = &sync.Mutex{} task.speedArr = make([]int64, 0) task.uploadSpeedArr = make([]int64, 0) diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 9de01a78..98ada366 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -102,6 +103,7 @@ func (m *generationTestManager) Close() error { return nil } type generationTestFetcher struct { manager *generationTestManager meta *fetcher.FetcherMeta + stats *fetcher.Stats done chan error } @@ -152,11 +154,48 @@ func (f *generationTestFetcher) Pause() error { return nil } func (f *generationTestFetcher) Close() error { return nil } -func (f *generationTestFetcher) Stats() any { return nil } +func (f *generationTestFetcher) Stats() *fetcher.Stats { return f.stats } func (f *generationTestFetcher) Meta() *fetcher.FetcherMeta { return f.meta } func (f *generationTestFetcher) Progress() fetcher.Progress { return fetcher.Progress{1} } func (f *generationTestFetcher) Wait() error { return <-f.done } +type recoveryTestData struct { + SeedBytes int64 +} + +type recoveryTestManager struct { + *generationTestManager +} + +func newRecoveryTestManager() *recoveryTestManager { + return &recoveryTestManager{generationTestManager: &generationTestManager{}} +} + +func (m *recoveryTestManager) Build() fetcher.Fetcher { + return &recoveryTestFetcher{ + generationTestFetcher: &generationTestFetcher{manager: m.generationTestManager, meta: &fetcher.FetcherMeta{}}, + data: &recoveryTestData{}, + } +} + +func (m *recoveryTestManager) Store(f fetcher.Fetcher) (any, error) { + return f.(*recoveryTestFetcher).data, nil +} + +func (m *recoveryTestManager) Restore() (any, func(*fetcher.FetcherMeta, any) fetcher.Fetcher) { + return &recoveryTestData{}, func(meta *fetcher.FetcherMeta, value any) fetcher.Fetcher { + return &recoveryTestFetcher{ + generationTestFetcher: &generationTestFetcher{manager: m.generationTestManager, meta: meta}, + data: value.(*recoveryTestData), + } + } +} + +type recoveryTestFetcher struct { + *generationTestFetcher + data *recoveryTestData +} + func newTestDownloadOpt(t *testing.T) *base.Options { t.Helper() return newTestDownloadOptAt(t.TempDir()) @@ -737,6 +776,74 @@ func TestDownloader_Create(t *testing.T) { } } +func TestDownloader_CreateDirectUsesPathAsDefault(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer func() { + downloader.Delete(nil, true) + downloader.Clear() + }() + + initialDir := t.TempDir() + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.DownloadDir = initialDir + if err := downloader.PutConfig(cfg); err != nil { + t.Fatal(err) + } + + createAndWait := func(opts *base.Options) { + t.Helper() + done := make(chan struct{}, 1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + done <- struct{}{} + } + }) + req := &base.Request{URL: "http://" + listener.Addr().String() + "/" + test.BuildName} + if _, err := downloader.CreateDirect(req, opts); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("task did not finish") + } + } + + selectedRoot := t.TempDir() + defaultPathOpts := newTestDownloadOptAt(selectedRoot) + defaultPathOpts.AsDefaultPath = true + createAndWait(defaultPathOpts) + cfg, err = downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DownloadDir != selectedRoot { + t.Fatalf("DownloadDir = %q, want selected root %q", cfg.DownloadDir, selectedRoot) + } + + cfg.DownloadDir = initialDir + if err := downloader.PutConfig(cfg); err != nil { + t.Fatal(err) + } + createAndWait(newTestDownloadOpt(t)) + cfg, err = downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DownloadDir != initialDir { + t.Fatalf("DownloadDir = %q, want unchanged %q", cfg.DownloadDir, initialDir) + } +} + func TestDownloader_CreateNotInWhite(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() @@ -1335,6 +1442,313 @@ func TestDownloader_Stats(t *testing.T) { if stats == nil { t.Error("Stats() returned nil stats") } + statsFrame, ok := stats.(*fetcher.Stats) + if !ok { + t.Fatalf("Stats() type = %T, want *fetcher.Stats", stats) + } + httpStats, ok := statsFrame.Snapshot.(*http.Stats) + if !ok { + t.Fatalf("Stats().Snapshot type = %T, want *http.Stats", statsFrame.Snapshot) + } + for index, connection := range httpStats.Connections { + if !connection.Completed && !connection.Failed { + t.Fatalf("completed task connection %d was still active", index) + } + } +} + +func TestDownloader_TaskStatsPersistsSnapshotWithoutRuntime(t *testing.T) { + downloader := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(t.TempDir())}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + task := &Task{ + ID: "stats-snapshot", + Status: base.DownloadStatusPause, + Progress: &Progress{}, + fetcher: &generationTestFetcher{stats: &fetcher.Stats{ + Snapshot: map[string]any{"downloaded": float64(42)}, + Runtime: map[string]any{"activePeers": float64(7)}, + }}, + } + initTask(task) + + if err := downloader.captureTaskStats(task, true, true); err != nil { + t.Fatal(err) + } + task.statsLock.Lock() + liveFrame := task.stats + task.statsLock.Unlock() + if liveFrame.Runtime == nil { + t.Fatal("running task lost runtime stats") + } + + // Simulate a freshly restored protocol that is still verifying its local + // pieces. A nil Snapshot must keep the last trusted persisted value while + // allowing current Runtime telemetry to refresh. + task.statsLock.Lock() + task.statsLoaded = false + task.stats = nil + task.statsSnapshot = nil + task.statsLock.Unlock() + task.fetcher.(*generationTestFetcher).stats = &fetcher.Stats{ + Runtime: map[string]any{"activePeers": float64(9)}, + } + if err := downloader.captureTaskStats(task, true, true); err != nil { + t.Fatal(err) + } + task.statsLock.Lock() + verifyingFrame := task.stats + task.statsLock.Unlock() + if verifyingFrame == nil || verifyingFrame.Runtime.(map[string]any)["activePeers"] != float64(9) { + t.Fatalf("verifying runtime = %+v", verifyingFrame) + } + if _, ok := verifyingFrame.Snapshot.(json.RawMessage); !ok { + t.Fatalf("verifying snapshot type = %T, want persisted json.RawMessage", verifyingFrame.Snapshot) + } + + // Simulate a fresh process: no fetcher and an empty task-level cache. + task.fetcher = nil + task.Status = base.DownloadStatusPause + task.statsLock.Lock() + task.statsLoaded = false + task.stats = nil + task.statsSnapshot = nil + task.statsLock.Unlock() + downloader.lock.Lock() + downloader.tasks = append(downloader.tasks, task) + downloader.lock.Unlock() + + restored, err := downloader.Stats(task.ID) + if err != nil { + t.Fatal(err) + } + restoredFrame := restored.(*fetcher.Stats) + if restoredFrame.Runtime != nil { + t.Fatalf("persisted runtime = %+v, want nil", restoredFrame.Runtime) + } + raw, ok := restoredFrame.Snapshot.(json.RawMessage) + if !ok { + t.Fatalf("persisted snapshot type = %T", restoredFrame.Snapshot) + } + var snapshot map[string]any + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot["downloaded"] != float64(42) { + t.Fatalf("persisted snapshot = %+v", snapshot) + } +} + +func TestDownloader_StatsDoesNotRestoreOrConsumeFetcherSave(t *testing.T) { + downloader := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(t.TempDir())}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + task := &Task{ID: "stats-no-restore", Status: base.DownloadStatusPause, Progress: &Progress{}} + initTask(task) + downloader.lock.Lock() + downloader.tasks = append(downloader.tasks, task) + downloader.lock.Unlock() + if err := downloader.storage.Put(bucketSave, task.ID, map[string]any{"downloaded": 12}); err != nil { + t.Fatal(err) + } + + stats, err := downloader.Stats(task.ID) + if err != nil { + t.Fatal(err) + } + if stats != nil { + t.Fatalf("missing task stats = %+v, want nil", stats) + } + var recovery map[string]any + exists, err := downloader.storage.Get(bucketSave, task.ID, &recovery) + if err != nil { + t.Fatal(err) + } + if !exists || recovery["downloaded"] != float64(12) { + t.Fatalf("fetcher recovery was consumed: exists=%v data=%+v", exists, recovery) + } +} + +func TestDownloader_RestoreFetcherDoesNotConsumeSave(t *testing.T) { + storage := NewMemStorage() + if err := storage.Setup([]string{bucketSave, bucketConfig}); err != nil { + t.Fatal(err) + } + manager := newRecoveryTestManager() + downloader := NewDownloader(&DownloaderConfig{ + Storage: storage, + FetchManagers: []fetcher.FetcherManager{manager}, + DownloaderStoreConfig: (&base.DownloaderStoreConfig{}).Init(), + }) + task := &Task{ + ID: "restore-keeps-checkpoint", + Meta: &fetcher.FetcherMeta{Req: &base.Request{URL: "generation://restore"}}, + Progress: &Progress{}, + fetcherManager: manager, + } + initTask(task) + if err := storage.Put(bucketSave, task.ID, &recoveryTestData{SeedBytes: 42}); err != nil { + t.Fatal(err) + } + + if err := downloader.restoreFetcher(task); err != nil { + t.Fatal(err) + } + restored := task.fetcher.(*recoveryTestFetcher).data + if restored.SeedBytes != 42 { + t.Fatalf("restored seed bytes = %d, want 42", restored.SeedBytes) + } + var checkpoint recoveryTestData + exists, err := storage.Get(bucketSave, task.ID, &checkpoint) + if err != nil { + t.Fatal(err) + } + if !exists || checkpoint.SeedBytes != 42 { + t.Fatalf("restore consumed checkpoint: exists=%v data=%+v", exists, checkpoint) + } +} + +func TestShouldSaveTaskCheckpoint(t *testing.T) { + if uploadCheckpointInterval != 30*time.Second { + t.Fatalf("upload checkpoint interval = %s, want 30s", uploadCheckpointInterval) + } + tests := []struct { + name string + downloadChanged bool + uploadChanged bool + uploading bool + uploadCheckpointDue bool + want bool + }{ + {name: "download changed", downloadChanged: true, want: true}, + {name: "upload changed", uploadChanged: true, want: true}, + {name: "active upload checkpoint due", uploading: true, uploadCheckpointDue: true, want: true}, + {name: "active upload checkpoint not due", uploading: true, want: false}, + {name: "inactive task checkpoint due", uploadCheckpointDue: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldSaveTaskCheckpoint( + tt.downloadChanged, + tt.uploadChanged, + tt.uploading, + tt.uploadCheckpointDue, + ) + if got != tt.want { + t.Fatalf("shouldSaveTaskCheckpoint() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDownloader_CompletedHTTPStatsSurviveRestore(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + storageDir := t.TempDir() + downloader := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir)}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + req := &base.Request{URL: "http://" + listener.Addr().String() + "/" + test.BuildName} + rr, err := downloader.Resolve(req, newTestDownloadOpt(t)) + if err != nil { + t.Fatal(err) + } + + done := make(chan struct{}, 1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyDone { + done <- struct{}{} + } + }) + taskID, err := downloader.Create(rr.ID) + if err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for HTTP task completion") + } + beforeRestoreValue, err := downloader.Stats(taskID) + if err != nil { + t.Fatal(err) + } + beforeRestore := decodeHTTPStatsSnapshot(t, beforeRestoreValue) + if err := downloader.Close(); err != nil { + t.Fatal(err) + } + + downloader = NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir)}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + + statsValue, err := downloader.Stats(taskID) + if err != nil { + t.Fatal(err) + } + stats := decodeHTTPStatsSnapshot(t, statsValue) + if len(stats.Connections) == 0 { + t.Fatal("restored completed task has no HTTP connection stats") + } + if !reflect.DeepEqual(stats, beforeRestore) { + t.Fatalf("restored stats changed\nbefore: %+v\nafter: %+v", beforeRestore, stats) + } + var downloaded int64 + for _, connection := range stats.Connections { + downloaded += connection.Downloaded + } + if downloaded != test.BuildSize { + t.Fatalf("restored connection downloads = %d, want %d", downloaded, test.BuildSize) + } + if err := downloader.Close(); err != nil { + t.Fatal(err) + } + + // Restoring stats must not consume the persisted snapshot. Completed tasks + // do not write another checkpoint, so verify a second restart as well. + downloader = NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir)}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + statsValue, err = downloader.Stats(taskID) + if err != nil { + t.Fatal(err) + } + secondRestore := decodeHTTPStatsSnapshot(t, statsValue) + if !reflect.DeepEqual(secondRestore, beforeRestore) { + t.Fatalf("stats changed after second restart\nbefore: %+v\nafter: %+v", beforeRestore, secondRestore) + } +} + +func decodeHTTPStatsSnapshot(t *testing.T, value any) *http.Stats { + t.Helper() + frame, ok := value.(*fetcher.Stats) + if !ok { + t.Fatalf("stats type = %T, want *fetcher.Stats", value) + } + switch snapshot := frame.Snapshot.(type) { + case *http.Stats: + return snapshot + case json.RawMessage: + var stats http.Stats + if err := json.Unmarshal(snapshot, &stats); err != nil { + t.Fatal(err) + } + return &stats + default: + t.Fatalf("HTTP snapshot type = %T", frame.Snapshot) + return nil + } } func TestDownloader_Delete(t *testing.T) { @@ -1480,6 +1894,115 @@ func TestDownloader_PauseAndContinue(t *testing.T) { downloader.Delete(nil, true) } +func TestDownloader_FailedTaskSurvivesRestart(t *testing.T) { + storageDir := t.TempDir() + manager := &generationTestManager{holdOpen: true} + downloader := NewDownloader(&DownloaderConfig{ + FetchManagers: []fetcher.FetcherManager{manager}, + Storage: NewBoltStorage(storageDir), + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + + errorEvent := make(chan error, 1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyError { + errorEvent <- event.Err + } + }) + taskID, err := downloader.CreateDirect(&base.Request{URL: "generation://restart-failure"}, &base.Options{ + Path: t.TempDir(), + Name: "failed.bin", + }) + if err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusRunning, 2*time.Second) + + wantErr := errors.New("download failed before restart") + downloader.GetTask(taskID).fetcher.(*generationTestFetcher).done <- wantErr + select { + case got := <-errorEvent: + if !errors.Is(got, wantErr) { + t.Fatalf("error event = %v, want %v", got, wantErr) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for task failure") + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusError, 2*time.Second) + + var stored Task + exists, err := downloader.storage.Get(bucketTask, taskID, &stored) + if err != nil { + t.Fatal(err) + } + if !exists || stored.Status != base.DownloadStatusError { + t.Fatalf("persisted task status = %s, exists = %t; want error", stored.Status, exists) + } + if err := downloader.Close(); err != nil { + t.Fatal(err) + } + + downloader = NewDownloader(&DownloaderConfig{ + FetchManagers: []fetcher.FetcherManager{manager}, + Storage: NewBoltStorage(storageDir), + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + if got := downloader.GetTask(taskID).Status; got != base.DownloadStatusError { + t.Fatalf("restored task status = %s, want error", got) + } +} + +func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) { + manager := &generationTestManager{holdOpen: true} + downloader := NewDownloader(&DownloaderConfig{FetchManagers: []fetcher.FetcherManager{manager}}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + taskID, err := downloader.CreateDirect(&base.Request{URL: "generation://auto-start"}, &base.Options{ + Path: t.TempDir(), + Name: "auto-start.bin", + }) + if err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusRunning, 2*time.Second) + if err := downloader.Pause(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.Extra = map[string]any{"autoStartTasks": true} + if err := downloader.ContinueOnStartup(); err != nil { + t.Fatal(err) + } + if got := downloader.taskStatus(downloader.GetTask(taskID)); got != base.DownloadStatusPause { + t.Fatalf("task status with auto-start disabled = %s, want pause", got) + } + + cfg.AutoStartTasks = true + if err := downloader.ContinueOnStartup(); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusRunning, 2*time.Second) + + downloader.GetTask(taskID).fetcher.(*generationTestFetcher).done <- errors.New("retry on next startup") + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusError, 2*time.Second) + if err := downloader.ContinueOnStartup(); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusRunning, 2*time.Second) +} + func TestDownloader_PauseAllAndContinueAll(t *testing.T) { const targetDownloadDuration = time.Second // Scale the per-byte delay with the configured connection count so a full @@ -1583,6 +2106,46 @@ func TestDownloader_GetTask(t *testing.T) { } } +func TestDownloader_RuntimeStatus(t *testing.T) { + meta := &fetcher.FetcherMeta{ + Res: &base.Resource{ + Size: 20, + Files: []*base.FileInfo{ + {Name: "skipped.bin", Size: 10}, + {Name: "selected.bin", Size: 20}, + }, + }, + Opts: &base.Options{SelectFiles: []int{1}}, + } + task := &Task{ + ID: "runtime-status", + Status: base.DownloadStatusRunning, + Meta: meta, + Progress: &Progress{Used: 11, Speed: 12, Downloaded: 13, UploadSpeed: 14, Uploaded: 15}, + fetcher: &generationTestFetcher{manager: &generationTestManager{}, meta: meta}, + } + initTask(task) + downloader := &Downloader{lock: &sync.Mutex{}, tasks: []*Task{task}} + + status, err := downloader.RuntimeStatus(task.ID) + if err != nil { + t.Fatal(err) + } + if status.Status != base.DownloadStatusRunning || status.Total != 20 || status.Downloaded != 13 { + t.Fatalf("unexpected runtime status: %+v", status) + } + if len(status.Files) != 1 { + t.Fatalf("len(status.Files) = %d, want 1", len(status.Files)) + } + file := status.Files[0] + if file.Index != 1 || file.Size != 20 || file.Downloaded != 1 { + t.Fatalf("unexpected file runtime status: %+v", file) + } + if _, err := downloader.RuntimeStatus("missing"); !errors.Is(err, ErrTaskNotFound) { + t.Fatalf("missing task error = %v, want ErrTaskNotFound", err) + } +} + func TestDownloader_Emit(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { diff --git a/pkg/download/model.go b/pkg/download/model.go index 8472d55a..0705827b 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -36,6 +36,10 @@ type Task struct { timer *util.Timer statusLock *sync.Mutex lock *sync.Mutex + statsLock *sync.Mutex + statsLoaded bool + stats *fetcher.Stats + statsSnapshot json.RawMessage blobRefLock *sync.Mutex blobURL string runGeneration uint64 @@ -43,6 +47,31 @@ type Task struct { uploadSpeedArr []int64 } +// TaskRuntimeStatus is the lightweight, frequently refreshed view of a task. +// It intentionally excludes request, resource and option metadata returned by +// Task so detail pages can poll progress without repeatedly transferring the +// full task object. +type TaskRuntimeStatus struct { + Status base.Status `json:"status"` + Used int64 `json:"used"` + Speed int64 `json:"speed"` + Downloaded int64 `json:"downloaded"` + Total int64 `json:"total"` + UploadSpeed int64 `json:"uploadSpeed"` + Uploaded int64 `json:"uploaded"` + ExtractStatus ExtractStatus `json:"extractStatus"` + ExtractProgress int `json:"extractProgress"` + Files []FileRuntimeStatus `json:"files"` +} + +// FileRuntimeStatus maps one value returned by fetcher.Progress to its +// original resource index. Fetchers return progress in SelectFiles order. +type FileRuntimeStatus struct { + Index int `json:"index"` + Size int64 `json:"size"` + Downloaded int64 `json:"downloaded"` +} + func NewTask() *Task { id, err := gonanoid.New() if err != nil { @@ -88,10 +117,10 @@ func (t *Task) Name() string { func (t *Task) MarshalJSON() ([]byte, error) { type rawTaskType Task jsonTask := struct { - rawTaskType + *rawTaskType Name string `json:"name"` }{ - rawTaskType(*t), + (*rawTaskType)(t), t.Name(), } return json.Marshal(jsonTask) diff --git a/pkg/protocol/bt/model.go b/pkg/protocol/bt/model.go index b106b613..98039d3d 100644 --- a/pkg/protocol/bt/model.go +++ b/pkg/protocol/bt/model.go @@ -1,19 +1,35 @@ package bt +import "github.com/GopeedLab/gopeed/pkg/base" + type ReqExtra struct { Trackers []string `json:"trackers"` } -// Stats for torrent -type Stats struct { - // health indicators of torrents, from large to small, ConnectedSeeders are also the key to the health of seed resources - TotalPeers int `json:"totalPeers"` - ActivePeers int `json:"activePeers"` - ConnectedSeeders int `json:"connectedSeeders"` +// StatsSnapshot contains cumulative BitTorrent data that remains meaningful +// after the torrent is stopped or the application restarts. +type StatsSnapshot struct { // Total seed bytes SeedBytes int64 `json:"seedBytes"` // Seed ratio SeedRatio float64 `json:"seedRatio"` // Total seed time SeedTime int64 `json:"seedTime"` + // PieceMap contains verified local piece completion in compact bitset-v1 + // format. It is nil while metadata or storage completion is unavailable. + PieceMap *base.PieceMap `json:"pieceMap"` +} + +// StatsRuntime contains live swarm telemetry. It is never persisted because +// peer connections and live swarm counters cease to be valid once the fetcher +// stops. +type StatsRuntime struct { + // Health indicators of torrents, from large to small. ConnectedSeeders are + // also the key to the health of seed resources. + TotalPeers int `json:"totalPeers"` + ActivePeers int `json:"activePeers"` + ConnectedSeeders int `json:"connectedSeeders"` + ConnectedLeechers int `json:"connectedLeechers"` + // Peers contains currently connected BitTorrent peers. + Peers []*base.PeerStats `json:"peers"` } diff --git a/pkg/protocol/ed2k/model.go b/pkg/protocol/ed2k/model.go index 226a39bd..5e590598 100644 --- a/pkg/protocol/ed2k/model.go +++ b/pkg/protocol/ed2k/model.go @@ -1,14 +1,21 @@ package ed2k -type Stats struct { - State string `json:"state"` - Paused bool `json:"paused"` - ActivePeers int `json:"activePeers"` - TotalPeers int `json:"totalPeers"` - DownloadRate int `json:"downloadRate"` - Upload int64 `json:"upload"` - UploadRate int `json:"uploadRate"` - TotalDone int64 `json:"totalDone"` - TotalReceived int64 `json:"totalReceived"` - TotalWanted int64 `json:"totalWanted"` +import "github.com/GopeedLab/gopeed/pkg/base" + +type StatsSnapshot struct { + Upload int64 `json:"upload"` + TotalDone int64 `json:"totalDone"` + TotalReceived int64 `json:"totalReceived"` + TotalWanted int64 `json:"totalWanted"` + PieceMap *base.PieceMap `json:"pieceMap"` +} + +type StatsRuntime struct { + State string `json:"state"` + Paused bool `json:"paused"` + ActivePeers int `json:"activePeers"` + TotalPeers int `json:"totalPeers"` + DownloadRate int `json:"downloadRate"` + UploadRate int `json:"uploadRate"` + Peers []*base.PeerStats `json:"peers"` } diff --git a/pkg/protocol/http/model.go b/pkg/protocol/http/model.go index 3199c981..9e96ffe2 100644 --- a/pkg/protocol/http/model.go +++ b/pkg/protocol/http/model.go @@ -30,6 +30,11 @@ type Stats struct { type StatsConnection struct { Downloaded int64 `json:"downloaded"` + // Total is the resource size divided by the current number of connections, + // rounded up to a whole byte. Every connection in one snapshot uses the same + // denominator so clients can compare their downloaded contributions. It is + // zero when the resource size is unknown. + Total int64 `json:"total"` Completed bool `json:"completed"` Failed bool `json:"failed"` RetryTimes int `json:"retryTimes"` diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 6c72bfb9..888c3cc6 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -197,6 +197,25 @@ func GetTask(w http.ResponseWriter, r *http.Request) { WriteJson(w, model.NewOkResult(task)) } +func GetTaskStatus(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + taskId := vars["id"] + if taskId == "" { + WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) + return + } + status, err := Downloader.RuntimeStatus(taskId) + if err != nil { + if err == download.ErrTaskNotFound { + WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound)) + return + } + WriteJson(w, model.NewErrorResult(err.Error())) + return + } + WriteJson(w, model.NewOkResult(status)) +} + func GetTasks(w http.ResponseWriter, r *http.Request) { filter, errResult := parseFilter(r) if errResult != nil { diff --git a/pkg/rest/server.go b/pkg/rest/server.go index ae3d9387..99fd9f35 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -35,6 +35,11 @@ var ( Downloader *download.Downloader ) +const ( + webAuthCookieName = "gopeed_web_auth" + webAuthTokenTTL = 7 * 24 * time.Hour +) + func Start(startCfg *model.StartConfig) (port int, err error) { // avoid repeat start if srv != nil { @@ -118,6 +123,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error if err != nil { return nil, nil, err } + if err := Downloader.ContinueOnStartup(); err != nil { + Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed") + } var r = mux.NewRouter() r.Methods(http.MethodGet).Path("/api/v1/info").HandlerFunc(Info) @@ -132,6 +140,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask) r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks) r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask) + r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/status").HandlerFunc(GetTaskStatus) r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/stats").HandlerFunc(GetStats) r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) @@ -164,7 +173,17 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error return } - WriteJson(w, model.NewOkResult(token)) + http.SetCookie(w, &http.Cookie{ + Name: webAuthCookieName, + Value: token, + Path: "/", + Expires: time.Now().Add(webAuthTokenTTL), + MaxAge: int(webAuthTokenTTL.Seconds()), + HttpOnly: true, + Secure: requestUsesHTTPS(r), + SameSite: http.SameSiteLaxMode, + }) + WriteJson(w, model.NewNilResult()) return } } @@ -182,6 +201,16 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if enableWebAuth && isProtectedWebFilePath(r.URL.Path) { + cookie, err := r.Cookie(webAuthCookieName) + if err != nil || !validWebAuthToken(cookie.Value, startCfg.WebAuth) { + writeUnauthorized(w, r) + return + } + h.ServeHTTP(w, r) + return + } + if enableApiToken { apiTokenHeader := r.Header["X-Api-Token"] // If api token header is set, only check api token ignore basic auth @@ -202,30 +231,8 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error return } - token := r.Header.Get("Authorization") - if token == "" { - writeUnauthorized(w, r) - return - } - - token = strings.TrimPrefix(token, "Bearer ") - tokenData, err := aesDecrypt(aesKey, token) - if err != nil { - writeUnauthorized(w, r) - return - } - parts := strings.SplitN(string(tokenData), ":", 3) - username := parts[0] - password := parts[1] - timestamp, _ := strconv.Atoi(parts[2]) - - if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password { - writeUnauthorized(w, r) - return - } - - // Check if the token is expired (7 days) - if time.Now().Unix()-int64(timestamp) > 7*24*3600 { + cookie, err := r.Cookie(webAuthCookieName) + if err != nil || !validWebAuthToken(cookie.Value, startCfg.WebAuth) { writeUnauthorized(w, r) return } @@ -261,6 +268,37 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error return srv, listener, nil } +func isProtectedWebFilePath(urlPath string) bool { + return urlPath == "/fs" || strings.HasPrefix(urlPath, "/fs/") +} + +func validWebAuthToken(token string, auth *model.WebAuth) bool { + if token == "" || auth == nil { + return false + } + tokenData, err := aesDecrypt(aesKey, token) + if err != nil { + return false + } + parts := strings.SplitN(string(tokenData), ":", 3) + if len(parts) != 3 || parts[0] != auth.Username || parts[1] != auth.Password { + return false + } + timestamp, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return false + } + return time.Now().Unix()-timestamp <= int64(webAuthTokenTTL.Seconds()) +} + +func requestUsesHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + forwardedProto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + return strings.EqualFold(forwardedProto, "https") +} + func resolvePath(urlPath string, prefix string) (identity string, path string, err error) { // remove prefix clearPath := strings.TrimPrefix(urlPath, prefix) diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index a61933b6..ff9b4dc3 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -144,6 +144,23 @@ func TestCreateDirectTask(t *testing.T) { }) } +func TestGetTaskStatus(t *testing.T) { + doTest(func() { + resolved := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq) + taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{Rid: resolved.ID}) + status := httpRequestCheckOk[*download.TaskRuntimeStatus](http.MethodGet, "/api/v1/tasks/"+taskId+"/status", nil) + if status.Total != test.BuildSize { + t.Fatalf("task status total = %d, want %d", status.Total, test.BuildSize) + } + if status.Files == nil { + t.Fatal("task status files must be an empty array or contain file progress, not null") + } + + code, _ := httpRequest[*download.TaskRuntimeStatus](http.MethodGet, "/api/v1/tasks/missing/status", nil) + checkCode(code, model.CodeTaskNotFound) + }) +} + func TestCreateDirectTaskBatch(t *testing.T) { doTest(func() { reqs := make([]*base.CreateTaskBatchItem, 0) @@ -438,6 +455,7 @@ func TestGetAndPutConfig(t *testing.T) { doTest(func() { cfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) cfg.DownloadDir = "./download" + cfg.AutoStartTasks = true cfg.Extra = map[string]any{ "serverConfig": &Config{ Host: "127.0.0.1", @@ -809,16 +827,73 @@ func TestAuthorization(t *testing.T) { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } - token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth) - authToken := fmt.Sprintf("Bearer %s", token) - authHeaders := map[string]string{ - "Authorization": authToken, + status, loginHeaders, loginBody := doHttpRequest1(http.MethodPost, "/api/web/login", nil, cfg.WebAuth) + if status != http.StatusOK { + t.Fatalf("login status = %d, want %d", status, http.StatusOK) + } + var loginResult model.Result[any] + if err := json.Unmarshal(loginBody, &loginResult); err != nil { + t.Fatal(err) + } + if loginResult.Data != nil { + t.Fatalf("login response exposed session data: %#v", loginResult.Data) + } + loginResponse := &http.Response{Header: http.Header{"Set-Cookie": []string{loginHeaders["Set-Cookie"]}}} + cookies := loginResponse.Cookies() + if len(cookies) != 1 { + t.Fatalf("login cookies = %d, want 1", len(cookies)) + } + authCookie := cookies[0] + if authCookie.Name != webAuthCookieName || authCookie.Value == "" || authCookie.Path != "/" || + !authCookie.HttpOnly || authCookie.SameSite != http.SameSiteLaxMode || authCookie.MaxAge <= 0 { + t.Fatalf("unexpected login cookie: %#v", authCookie) + } + bearerHeaders := map[string]string{ + "Authorization": "Bearer " + authCookie.Value, + } + authCookieHeader := map[string]string{ + "Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), + } + + for _, filePath := range []string{ + "/fs/extensions/not_exist/icon.png", + "/fs/tasks/not_exist/file.bin", + } { + status, _ = doHttpRequest0(http.MethodGet, filePath, nil, nil) + if status != http.StatusUnauthorized { + t.Errorf("unauthenticated %s status = %d, want %d", filePath, status, http.StatusUnauthorized) + } + + status, _ = doHttpRequest0(http.MethodGet, filePath, bearerHeaders, nil) + if status != http.StatusUnauthorized { + t.Errorf("Bearer-only %s status = %d, want %d", filePath, status, http.StatusUnauthorized) + } + + status, _ = doHttpRequest0(http.MethodGet, filePath, authCookieHeader, nil) + if status != http.StatusNotFound { + t.Errorf("cookie-authenticated %s status = %d, want %d", filePath, status, http.StatusNotFound) + } + } + status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", nil, nil) + if status != http.StatusUnauthorized { + t.Errorf("unauthenticated future /fs route status = %d, want %d", status, http.StatusUnauthorized) + } + + status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{ + "Cookie": webAuthCookieName + "=invalid", + }, nil) + if status != http.StatusUnauthorized { + t.Errorf("invalid-cookie file status = %d, want %d", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", bearerHeaders, nil) + if status != http.StatusUnauthorized { + t.Errorf("Bearer-authenticated API status = %v, want %v", status, http.StatusUnauthorized) + } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": "xxx", @@ -841,7 +916,7 @@ func TestAuthorization(t *testing.T) { fakeToken := buildToken("fake", "fake", time.Now().Unix()) status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", fakeToken), + "Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, fakeToken), }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) @@ -849,13 +924,19 @@ func TestAuthorization(t *testing.T) { expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix()) status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", expireToken), + "Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, expireToken), }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } + status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{ + "Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, expireToken), + }, nil) + if status != http.StatusUnauthorized { + t.Errorf("expired-cookie file status = %d, want %d", status, http.StatusUnauthorized) + } - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil) + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authCookieHeader, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } @@ -868,16 +949,16 @@ func TestAuthorization(t *testing.T) { } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": authToken, - "X-Api-Token": cfg.ApiToken, + "Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), + "X-Api-Token": cfg.ApiToken, }, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": authToken, - "X-Api-Token": "", + "Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), + "X-Api-Token": "", }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) diff --git a/ui/flutter/.gitignore b/ui/flutter/.gitignore index 9e9c2f15..7f8cd131 100644 --- a/ui/flutter/.gitignore +++ b/ui/flutter/.gitignore @@ -5,9 +5,12 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ +migrate_working_dir/ # IntelliJ related *.iml @@ -29,10 +32,18 @@ .packages .pub-cache/ .pub/ -build/ +/build/ +/coverage/ .fvm/ -# Web related +# Generated by `flutter gen-l10n` from lib/l10n/*.arb. +/lib/l10n/app_localizations*.dart + +# Native helpers are produced for the target architecture during packaging. +assets/exec/host +assets/exec/host.exe +assets/exec/updater +assets/exec/updater.exe # Symbolication related app.*.symbols @@ -40,10 +51,12 @@ app.*.symbols # Obfuscation related app.*.map.json -# Exceptions to above rules. -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release -# Libgopeed +# Libgopeed build outputs windows/libgopeed.h windows/libgopeed.dll macos/Frameworks/libgopeed.h @@ -53,8 +66,10 @@ linux/bundle/lib/libgopeed.dylib android/app/libs/libgopeed.aar android/app/libs/libgopeed-sources.jar +# Local packaging outputs debian/packages +# Flutter generated plugin registrants linux/flutter/generated_plugin_registrant.cc linux/flutter/generated_plugin_registrant.h linux/flutter/generated_plugins.cmake @@ -63,13 +78,7 @@ windows/flutter/generated_plugin_registrant.cc windows/flutter/generated_plugin_registrant.h windows/flutter/generated_plugins.cmake +# Runtime data /extensions - -# Hive database files database.hive database.lock - -assets/exec/host -assets/exec/host.exe -assets/exec/updater -assets/exec/updater.exe diff --git a/ui/flutter/.metadata b/ui/flutter/.metadata index 33f93bc1..51c0bb52 100644 --- a/ui/flutter/.metadata +++ b/ui/flutter/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: 7048ed95a5ad3e43d697e0c397464193991fc230 - channel: stable + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" project_type: app @@ -13,11 +13,26 @@ project_type: app migration: platforms: - platform: root - create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 - base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: android + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: ios + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: linux + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: macos + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: web + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a - platform: windows - create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 - base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a # User provided section diff --git a/ui/flutter/AGENTS.md b/ui/flutter/AGENTS.md new file mode 100644 index 00000000..484e28a0 --- /dev/null +++ b/ui/flutter/AGENTS.md @@ -0,0 +1,272 @@ +# AGENTS.md + +This document defines the implementation standards for this Flutter project. + +## 1) Product Scope + +- This is a cross-platform app that must support both desktop and mobile. +- Every page must use responsive layouts and behave correctly across: + - Mobile phone sizes + - Tablet sizes + - Desktop window sizes, including resizable windows +- Desktop is currently the primary design target, but the architecture must remain ready for future mobile-specific variants. + +## 2) Required Project Structure + +Use the following structure as the baseline and do not introduce alternative top-level patterns: + +```text +lib/ +├── main.dart +├── app/ +│ ├── app.dart +│ ├── router/ +│ │ ├── app_router.dart +│ │ └── shells/ +│ │ └── main_shell.dart +│ └── application/ +│ └── locale_controller.dart +├── core/ +│ ├── network/ +│ ├── error/ +│ └── utils/ +├── shared/ +│ └── widgets/ +├── features/ +│ ├── auth/ +│ ├── tasks/ +│ ├── extensions/ +│ └── settings/ +└── l10n/ + ├── app_en.arb + ├── app_zh.arb + └── app_zh_TW.arb +``` + +## 2.1) Core Tech Stack + +Use the following as the baseline stack for this project: + +- `flutter_riverpod` +- `go_router` +- `flutter_localizations` +- `dio` +- `window_manager` +- `tray_manager` +- `desktop_multi_window` +- `shadcn_flutter` + +Guidelines: + +- Do not introduce alternative frameworks for the same responsibility unless explicitly approved. +- Keep app-level state aligned with Riverpod patterns when new shared state is introduced. +- Keep navigation centralized in `lib/app/router/`. +- Keep network access built on top of Dio abstractions in `lib/core/network/`. +- Keep desktop window, tray, and multi-window logic encapsulated and platform-guarded. +- `shadcn_flutter` is the default product UI system for app surfaces and controls. + +## 3) UI Design Rules + +- Maintain a consistent visual language across desktop and mobile. +- Define and reuse design tokens for color, spacing, radius, typography, elevation, and motion. +- Avoid hard-coded one-off styles in page files; prefer centralized tokens and shared primitives. +- Preserve accessibility and usable contrast in both light and dark themes. +- Respect platform ergonomics: + - Mobile: larger tap areas, reduced density, simpler layouts + - Desktop: hover states, richer density, resize-aware layouts, window-aware composition + +## 3.1) Theme Extension Rules + +- Custom app colors must be exposed through Flutter `ThemeExtension`, not scattered as widget-local hard-coded values. +- Keep the app's semantic color tokens centralized in the shared theme layer under `lib/shared/theme/`. +- Widgets should read custom colors via shared theme accessors such as `AppPalette.of(context)` or `Theme.of(context).extension<...>()`, not by repeating raw hex values. +- Raw `Color(0x...)` values are allowed only inside the shared theme definition layer, platform interop code, or when a plugin API requires a direct color constant. +- When a new component needs a design-specific color, first promote it to a semantic theme token such as `taskCardHover`, `headerDivider`, or `captionButtonHover` instead of adding another local constant. +- If both `shadcn_flutter` theme data and Flutter `ThemeData` are used, keep them mapped from the same source-of-truth tokens so light and dark mode stay aligned. + +## 3.2) Design Source of Truth + +- When a screen or component exists under `design/`, Flutter must match its layout, spacing, hierarchy, and motion behavior before introducing local reinterpretation. +- Preserve the app's existing light and dark palette values as the canonical color source of truth even if the H5 prototype uses different colors. +- Do not copy H5 colors blindly. Rebuild structure and interaction fidelity using the Flutter theme tokens. + +## 3.3) shadcn_flutter Rules + +- Use `shadcn_flutter` as the default UI component library for product-facing surfaces. +- Use the `shadcn_flutter` documentation reference at: + - [llms-full.txt](https://sunarya-thito.github.io/shadcn_flutter/llms-full.txt) +- Prefer `import 'package:shadcn_flutter/shadcn_flutter.dart';` as the primary UI import form. +- `shadcn_flutter` should have the highest import priority in product UI files. Keep it unaliased by default, and only add aliases to secondary packages when needed to avoid symbol conflicts or to clarify source ownership. +- Prefer `ShadcnApp` / `ShadcnApp.router` at the app shell level. +- Prefer `shadcn_flutter` controls such as buttons, inputs, tabs, dividers, and overlays over Material-style product UI widgets. +- Material may remain only where required for platform/plugin interop or Flutter internals, not as the default product design system. +- Product-facing interactive widgets must not use Flutter Material components such as `TextField`, `SnackBar`, `AlertDialog`, `showDialog`, `TextButton`, `ElevatedButton`, `OutlinedButton`, `IconButton`, `Drawer`, or similar Material UI controls when an equivalent `shadcn_flutter` primitive exists. +- A `material.dart` import is acceptable only for low-level primitives such as `Icons`, `Colors`, `ChangeNotifier`, geometry, painting, focus, text editing, or platform/plugin interop. It must not be used as a shortcut to build product UI controls. +- Tooltips are a documented exception to the Material-component restriction: always use the shared `AppTooltip` from `lib/shared/widgets/app_tooltip.dart`. It intentionally wraps Flutter's Material `Tooltip` because `shadcn_flutter`'s tooltip does not inherit the app's mixed Shadcn/Material theme consistently, which can produce a transparent-looking background and oversized text. +- Never instantiate either Flutter's `Tooltip` or `shadcn_flutter`'s `Tooltip` directly in feature/page code. Keep tooltip messages localized and route all product tooltip styling and behavior through `AppTooltip`. +- When auditing UI code, distinguish between: + - acceptable Material foundation usage + - forbidden Material product-component usage +- If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`. + +## 3.4) Desktop Design Metrics + +Use the following desktop reference metrics unless a specific screen requires a documented exception: + +- Reference window: `1024 x 768` +- Primary rail width: `64px` +- Secondary filter sidebar width: `256px` +- Main content header height: `80px` +- Window drag header height on Windows: `30px` +- Task row height: `72px` +- Standard control radius: `4px` +- Window / overlay radius: `12px` +- Spacing rhythm: `4 / 8 / 12 / 16 / 24 / 32` + +## 3.5) Motion Rules + +- Motion is part of the spec, not decoration. +- Recreate the design interactions when present in `design/`, including: + - Progress shimmer / specular sweep + - Indeterminate progress motion + - Animated speed gauges / meters + - Drawer slide-in transitions + - Hover and selection transitions + - Batch-mode reveal transitions +- Centralize durations, curves, and motion constants whenever motion becomes shared across multiple widgets. + +## 4) Responsive Layout Rules + +- All screens must be responsive by default. +- Never build a page for a single fixed size only. +- Use adaptive patterns such as: + - `LayoutBuilder` + - breakpoints + - `Flexible` / `Expanded` + - desktop and mobile variants when needed +- Verify every major page on at least: + - small mobile width + - medium / tablet width + - desktop width + +## 5) Component Decomposition Rules + +### 5.1) Split aggressively into small files + +- If a child widget is reusable or has an independent UI responsibility, move it to a separate file. +- Do not keep large page files with many inline private widgets if those sections can be extracted cleanly. +- Keep widgets focused and single-purpose. + +### 5.2) Two levels of component abstraction + +1. App-shared, non-business components + - Place in `lib/shared/widgets/` + - Examples: buttons, headers, chips, cards, window chrome, reusable form controls + - Must stay business-agnostic and design-system aligned + +2. Feature or page-specific components + - Place close to usage inside the feature module + - Recommended paths: + - `lib/features//presentation/pages/` + - `lib/features//presentation/widgets/` + +### 5.3) Separation guidance + +- Shared first when the component is used by multiple features or is purely design-system UI. +- Feature-local when the component is tightly coupled to one feature's state or interaction model. +- Promote from feature-local to shared only when reuse is real, not hypothetical. + +### 5.4) Dual-platform component pattern + +When mobile and desktop variants differ significantly, do not keep both branches inside one large widget file. + +Required pattern: + +```text +lib/features//presentation/widgets// +├── .dart +├── _mobile.dart +└── _desktop.dart +``` + +Rules: + +- Only import the facade file from pages or sibling widgets. +- Keep business logic outside the mobile and desktop view files when possible. +- If a shared component also needs this split, apply the same pattern under `lib/shared/widgets//`. + +## 6) Desktop Window and Chrome Rules + +- Do not simulate OS switching inside the app UI. +- Adapt window chrome to the actual running platform: + - macOS: prefer native system chrome behavior + - Windows: use custom frameless chrome + - Linux: prefer native behavior unless custom frameless support is explicitly verified as stable +- The desktop window header / chrome must be implemented as a reusable shared component so it can be reused by: + - the main window + - the create-task child window + - future child windows +- Keep desktop window infrastructure in `lib/core/window/`. +- Keep reusable visual window header components under `lib/shared/widgets/window/` or an equivalent shared path. + +## 6.1) Cross-Window Capability Rules + +- Follow the mandatory architecture in [`docs/window-capability-rpc.md`](docs/window-capability-rpc.md) for all communication between the main window and desktop child windows. +- The main window is the sole owner of the Gopeed runtime, Gopeed API connection, and Hive database. +- Child windows must use `AppCapabilities`; they must not initialize Gopeed, open Hive, import `lib/api/api.dart`, or access `Database.instance`. +- Add new cross-window operations through shared `RpcMethod` descriptors and the central codec/registry. Do not add feature-specific MethodChannels or duplicate host/client serialization wrappers. +- Main-to-child state updates must use centrally declared events with complete state snapshots. + +## 7) Page Implementation Standard + +For each page: + +- Keep the page entry widget in a page file. +- Move each meaningful sub-section into dedicated widget files. +- Keep business state and side effects outside pure presentational widgets where practical. +- Keep responsive logic explicit and readable. +- Reuse shared tokens and shared window/header primitives instead of rebuilding them locally. +- Desktop pages and desktop child windows must reserve `AppDesignTokens.windowHeaderHeight` at the top of their root content surface before the first visible content starts. This padding is owned by the page/window body itself, including create-task child windows, rather than being injected by a global shell. + +## 8) Naming and Organization + +- Use clear, intention-revealing names. +- Favor suffixes: + - `...Page` + - `...Section` + - `...Card` + - `...Tile` + - `...Button` + - `...Header` +- Avoid placeholder names such as `Widget1`, `TempView`, or `CommonBox`. + +## 9) Localization and Text + +- All user-facing natural-language strings must be localizable. This includes widget copy, validation and error messages, empty states, dialogs, tooltips, accessibility labels, tray menus, desktop notifications, Android foreground-service notifications, and child-window copy. +- `lib/l10n/app_en.arb` is the source template. Add each new message to every `lib/l10n/app_.arb` catalog, keeping the same key set in every locale. Prefer a base locale such as `app_zh.arb`; add a region-qualified catalog such as `app_zh_TW.arb` only when it has a distinct selectable translation. +- In widgets, read messages through `context.l10n`. In non-widget or background code, resolve the configured/system locale through the shared helpers in `lib/l10n/l10n.dart`; do not introduce a separate translation lookup mechanism. +- Translate message values only, and never rename variables such as `{count}` or `{name}`. In a message like `{count, plural, =1{1 file} other{{count} files}}`, translate the user-visible branch text while keeping `count`, `plural`, branch selectors such as `=1`/`other`, and the braces valid. Preserve the corresponding ARB placeholder metadata, and do not translate metadata keys prefixed with `@`. +- Prefer parameterized messages over string concatenation when word order can vary by language. Give placeholders explicit metadata and Dart types in the English ARB when appropriate. +- Do not edit generated `app_localizations*.dart` files. Regenerate them from ARB sources with `flutter gen-l10n`. +- Language names in the locale selector should remain native autonyms. Protocol names, product names, URLs, version strings, and dynamic data do not need translation unless they form part of natural-language copy. +- Design for longer translations and right-to-left locales: avoid fixed text widths, use flexible/wrapping layouts where needed, and do not encode direction with left/right-only assumptions. +- Do not hard-code production UI copy directly in widgets. Prototype-only text must be localized before the feature is considered complete. +- Before completing localization work, run from `ui/flutter`: + +```sh +dart run ../../.github/workflows/scripts/check_l10n.dart +flutter gen-l10n +flutter analyze +flutter test +``` + +## 10) Quality Gate + +Before merging major UI work: + +- Verify desktop and mobile responsive behavior. +- Verify alignment and spacing consistency with shared tokens. +- Verify widget decomposition follows shared versus feature-local rules. +- Verify no obvious style duplication remains in page files where shared components are appropriate. +- Verify desktop window chrome behavior is correct for the target platform. +- Verify screens that exist in `design/` still match the intended layout and motion behavior. diff --git a/ui/flutter/README.md b/ui/flutter/README.md new file mode 100644 index 00000000..a0c66bdc --- /dev/null +++ b/ui/flutter/README.md @@ -0,0 +1,17 @@ +# Gopeed + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/ui/flutter/analysis_options.yaml b/ui/flutter/analysis_options.yaml index 61b6c4de..89f7f921 100644 --- a/ui/flutter/analysis_options.yaml +++ b/ui/flutter/analysis_options.yaml @@ -13,8 +13,7 @@ linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. + # and their documentation is published at https://dart.dev/lints. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code @@ -27,3 +26,6 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + +formatter: + page_width: 120 diff --git a/ui/flutter/android/.gitignore b/ui/flutter/android/.gitignore index 6f568019..cdb7f456 100644 --- a/ui/flutter/android/.gitignore +++ b/ui/flutter/android/.gitignore @@ -5,9 +5,11 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks +/app/libs/libgopeed.aar diff --git a/ui/flutter/android/app/build.gradle.kts b/ui/flutter/android/app/build.gradle.kts new file mode 100644 index 00000000..ce731ace --- /dev/null +++ b/ui/flutter/android/app/build.gradle.kts @@ -0,0 +1,143 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val apkKeyPassword = System.getenv("APK_KEY_PASSWORD") +val apkStorePassword = System.getenv("APK_STORE_PASSWORD") + +android { + namespace = "com.gopeed.gopeed" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.gopeed.gopeed" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + keyAlias = "upload" + keyPassword = apkKeyPassword + storeFile = file("upload-keystore.jks") + storePassword = apkStorePassword + } + } + + buildTypes { + release { + signingConfig = + if (!apkKeyPassword.isNullOrEmpty() && !apkStorePassword.isNullOrEmpty()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } + + packaging { + jniLibs { + // Go and Flutter native libraries are large and compress well. Gopeed is + // primarily distributed as APKs, so preserve the smaller legacy layout. + useLegacyPackaging = true + } + } +} + +flutter { + source = "../.." +} + +dependencies { + // Generated by gomobile from bind/mobile before an Android build. + implementation(files("libs/libgopeed.aar")) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + +// shadcn_flutter 0.0.53 unconditionally declares every Geist font and all +// country_flags assets. Android uses platform fonts below and Gopeed does not use +// PhoneInput/CountryFlag, so keep those transitive assets out of release APKs. +val pruneReleaseFlutterAssets = + tasks.register("pruneReleaseFlutterAssets") { + dependsOn("compileFlutterBuildRelease") + + val generatedFlutterAssets = + layout.buildDirectory.dir("intermediates/flutter/release/flutter_assets") + + doLast { + val flutterAssets = generatedFlutterAssets.get().asFile + if (!flutterAssets.isDirectory) { + throw GradleException("Generated Flutter assets not found: $flutterAssets") + } + + val dartSources = fileTree(file("../../lib")) { include("**/*.dart") } + val unsupportedUsage = Regex("\\b(CountryFlag|PhoneInput)\\b|Geist(?:Sans|Mono)") + val appTheme = file("../../lib/shared/theme/app_theme.dart").canonicalFile + val allowedWindowsFontReference = + "fontFamily: 'packages/shadcn_flutter/GeistSans'," + val usageFile = + dartSources.files.firstOrNull { sourceFile -> + val source = sourceFile.readText() + val guardedSource = + if (sourceFile.canonicalFile == appTheme) { + source.replaceFirst(allowedWindowsFontReference, "") + } else { + source + } + unsupportedUsage.containsMatchIn(guardedSource) + } + if (usageFile != null) { + throw GradleException( + "Android release asset pruning is incompatible with ${usageFile.relativeTo(projectDir)}", + ) + } + + listOf( + flutterAssets.resolve("packages/country_flags/res/si"), + flutterAssets.resolve("packages/shadcn_flutter/lib/fonts"), + ).forEach { assetDirectory -> + if (assetDirectory.exists() && !assetDirectory.deleteRecursively()) { + throw GradleException("Unable to prune Flutter assets: $assetDirectory") + } + } + + val fontManifest = flutterAssets.resolve("FontManifest.json") + if (!fontManifest.isFile) { + throw GradleException("Flutter font manifest not found: $fontManifest") + } + val fonts = JsonSlurper().parse(fontManifest) as List<*> + val optimizedFonts = + fonts.filterNot { entry -> + val family = (entry as? Map<*, *>)?.get("family") + family == "packages/shadcn_flutter/GeistSans" || + family == "packages/shadcn_flutter/GeistMono" + } + fontManifest.writeText(JsonOutput.toJson(optimizedFonts)) + } + } + +tasks.matching { it.name == "copyFlutterAssetsRelease" }.configureEach { + dependsOn(pruneReleaseFlutterAssets) +} diff --git a/ui/flutter/android/app/libs/.gitkeep b/ui/flutter/android/app/libs/.gitkeep index e69de29b..8b137891 100644 --- a/ui/flutter/android/app/libs/.gitkeep +++ b/ui/flutter/android/app/libs/.gitkeep @@ -0,0 +1 @@ + diff --git a/ui/flutter/android/app/src/debug/AndroidManifest.xml b/ui/flutter/android/app/src/debug/AndroidManifest.xml index c4a4b76b..399f6981 100644 --- a/ui/flutter/android/app/src/debug/AndroidManifest.xml +++ b/ui/flutter/android/app/src/debug/AndroidManifest.xml @@ -3,5 +3,5 @@ the Flutter tool needs it to communicate with the running application to allow setting breakpoints, to provide hot reload, etc. --> - - \ No newline at end of file + + diff --git a/ui/flutter/android/app/src/main/AndroidManifest.xml b/ui/flutter/android/app/src/main/AndroidManifest.xml index 56b8329c..69e93226 100644 --- a/ui/flutter/android/app/src/main/AndroidManifest.xml +++ b/ui/flutter/android/app/src/main/AndroidManifest.xml @@ -1,100 +1,77 @@ - + - - + + - + android:name="${applicationName}" + android:requestLegacyExternalStorage="true" + android:extractNativeLibs="true" + android:icon="@mipmap/ic_launcher"> - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> - - - - - - - - - - - - + + - + - + - + - + + + + + + + + + + + + + - @@ -110,26 +87,29 @@ - - - - - - + android:exported="false" + android:foregroundServiceType="dataSync" /> + + + + + + + diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt index d054f1da..162ab146 100644 --- a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt +++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt @@ -1,49 +1,41 @@ package com.gopeed.gopeed -import androidx.annotation.NonNull import com.gopeed.libgopeed.Libgopeed import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.StandardMethodCodec -open class MainActivity : FlutterActivity() { - private val CHANNEL = "gopeed.com/libgopeed" - - protected open fun isDialogMode(): Boolean = false - - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + val taskQueue = flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue() MethodChannel( flutterEngine.dartExecutor.binaryMessenger, - CHANNEL, + LIBGOPEED_CHANNEL, StandardMethodCodec.INSTANCE, - taskQueue + taskQueue, ).setMethodCallHandler { call, result -> when (call.method) { "start" -> { - val cfg = call.argument("cfg") try { - val port = Libgopeed.start(cfg) - result.success(port) - } catch (e: Exception) { - result.error("ERROR", e.message, null) + result.success(Libgopeed.start(call.argument("cfg"))) + } catch (error: Exception) { + result.error("ERROR", error.message, null) } } "stop" -> { Libgopeed.stop() result.success(null) } - "isDialogMode" -> { - result.success(isDialogMode()) - } - else -> { - result.notImplemented() - } + else -> result.notImplemented() } } } + private companion object { + const val LIBGOPEED_CHANNEL = "gopeed.com/libgopeed" + } } diff --git a/ui/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 7671b90a..06fb6924 100644 Binary files a/ui/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/ui/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ui/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 33a417fc..8f9548cb 100644 Binary files a/ui/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/ui/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ui/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 1a3b18ed..df605e5c 100644 Binary files a/ui/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/ui/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ui/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d1e9e867..83d605b1 100644 Binary files a/ui/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/ui/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ui/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b02a30c9..4ed0ace5 100644 Binary files a/ui/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/ui/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ui/flutter/android/app/src/main/res/values-night/styles.xml b/ui/flutter/android/app/src/main/res/values-night/styles.xml index 7c601a8b..06952be7 100644 --- a/ui/flutter/android/app/src/main/res/values-night/styles.xml +++ b/ui/flutter/android/app/src/main/res/values-night/styles.xml @@ -15,18 +15,4 @@ - - diff --git a/ui/flutter/android/app/src/main/res/values/styles.xml b/ui/flutter/android/app/src/main/res/values/styles.xml index 36c4b265..cb1ef880 100644 --- a/ui/flutter/android/app/src/main/res/values/styles.xml +++ b/ui/flutter/android/app/src/main/res/values/styles.xml @@ -15,18 +15,4 @@ - - diff --git a/ui/flutter/android/app/src/profile/AndroidManifest.xml b/ui/flutter/android/app/src/profile/AndroidManifest.xml index c4a4b76b..399f6981 100644 --- a/ui/flutter/android/app/src/profile/AndroidManifest.xml +++ b/ui/flutter/android/app/src/profile/AndroidManifest.xml @@ -3,5 +3,5 @@ the Flutter tool needs it to communicate with the running application to allow setting breakpoints, to provide hot reload, etc. --> - - \ No newline at end of file + + diff --git a/ui/flutter/android/build.gradle.kts b/ui/flutter/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/ui/flutter/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/ui/flutter/android/gradle.properties b/ui/flutter/android/gradle.properties index 5b3d6e64..fbee1d8c 100644 --- a/ui/flutter/android/gradle.properties +++ b/ui/flutter/android/gradle.properties @@ -1,6 +1,2 @@ -org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true -android.nonTransitiveRClass=true -android.nonFinalResIds=true -android.defaults.buildfeatures.buildconfig=true diff --git a/ui/flutter/android/gradle/wrapper/gradle-wrapper.properties b/ui/flutter/android/gradle/wrapper/gradle-wrapper.properties index 9162f100..e4ef43fb 100644 --- a/ui/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/ui/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/ui/flutter/android/settings.gradle.kts b/ui/flutter/android/settings.gradle.kts new file mode 100644 index 00000000..ca7fe065 --- /dev/null +++ b/ui/flutter/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ui/flutter/assets/fonts/GopeedIcons.ttf b/ui/flutter/assets/fonts/GopeedIcons.ttf new file mode 100644 index 00000000..525d6755 Binary files /dev/null and b/ui/flutter/assets/fonts/GopeedIcons.ttf differ diff --git a/ui/flutter/build.yaml b/ui/flutter/build.yaml index 9e73a339..b6fcc2c5 100644 --- a/ui/flutter/build.yaml +++ b/ui/flutter/build.yaml @@ -16,4 +16,4 @@ targets: field_rename: none generic_argument_factories: false ignore_unannotated: false - include_if_null: false \ No newline at end of file + include_if_null: false diff --git a/ui/flutter/distribute_options.yaml b/ui/flutter/distribute_options.yaml index 57792289..d04371b3 100644 --- a/ui/flutter/distribute_options.yaml +++ b/ui/flutter/distribute_options.yaml @@ -1 +1 @@ -output: dist/ \ No newline at end of file +output: dist/ diff --git a/ui/flutter/docs/window-capability-rpc.md b/ui/flutter/docs/window-capability-rpc.md new file mode 100644 index 00000000..1d90a8de --- /dev/null +++ b/ui/flutter/docs/window-capability-rpc.md @@ -0,0 +1,130 @@ +# Window Capability RPC Architecture + +This document defines the internal communication contract between the main window and desktop child windows. + +## 1. Ownership + +- The main window is the only owner of the Gopeed runtime, Gopeed API connection, and Hive database. +- Child windows must never initialize `libgopeed`, initialize the Gopeed HTTP client, or open a Hive box. +- Child windows access application capabilities through `AppCapabilities` only. +- Immutable window launch input, such as the window type and an initial create-task request, may use `AppWindowPayload`. +- API addresses, API tokens, appearance state, locale, and persisted data must not be placed in launch payloads. + +## 2. Layers + +```text +Presentation / Riverpod controllers + | + AppCapabilities + / \ + GopeedService AppStorageService + \ / + CapabilityInvoker + / \ + LocalCapability WindowCapability + Invoker Invoker + | | + CapabilityRegistry WindowMethodChannel + | | + Gopeed HTTP/FFI + Hive in the main window +``` + +The presentation layer must not branch on whether it is running in a main or child window. The root `ProviderScope` selects the local or remote capability implementation. + +## 3. RPC Contract + +Every operation is declared once as a typed `RpcMethod`: + +```dart +static const resolve = RpcMethod('gopeed.resolve'); +``` + +Rules: + +- Operation names are stable protocol identifiers and must be declared in a capability method catalog. +- Do not duplicate operation strings in pages, controllers, host handlers, or clients. +- Do not create one MethodChannel per operation. +- Do not expose Gopeed HTTP paths through the internal capability API. +- Multi-argument operations should use a JSON map or a dedicated request DTO. +- Prefer a dedicated request DTO when parameters have domain meaning or are likely to evolve. + +`RpcCodecRegistry` owns serialization. Encoding is generic for JSON primitives, collections, enums, and models with `toJson()`. Each non-primitive response or request model registers its `fromJson` decoder once. Individual operations must not repeat request and response codec closures. + +The local invoker calls the typed handler directly and does not perform a JSON round trip. Serialization occurs only at a window boundary. + +## 4. Transport + +Child-to-main requests use one unidirectional `WindowMethodChannel`: + +```text +gopeed.app.capabilities.v1 +``` + +The transport uses one method, `capability.call`, with an operation name and JSON payload. Results use a common success/error envelope. MethodChannel provides request-response correlation, so the protocol does not add request IDs for ordinary calls. + +The existing HTTP `HostRpcService` is an external browser-extension integration and is not the internal window capability transport. Its `/forward` endpoint must not be reused by child windows. + +## 5. Events And State Synchronization + +Main-to-child events use each child's `WindowController` channel. A child registers its method handler before subscribing. + +Initialization order: + +1. The child registers its window event handler. +2. The child requests the current appearance snapshot. +3. The child applies the snapshot. +4. The child subscribes with its window ID. +5. The main window stores the controller and immediately sends the current snapshot again. +6. Later state changes are broadcast to every subscribed child. + +Events contain complete state snapshots, not patches. No revision field is required. Event names are declared centrally in `AppWindowRpcProtocol`. + +Appearance synchronization uses `appearance.changed` and currently contains: + +- Theme mode +- Theme color +- Locale + +State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points. + +## 6. Storage + +- `Database` remains the low-level Hive implementation and is main-window infrastructure. +- Product code used by child windows depends on `AppStorageService`. +- Storage RPC methods expose business capabilities such as create-history operations, not raw `box.get` or `box.put` access. +- Prefer batch mutations across the window boundary. For example, save all parsed URLs in one `saveCreateHistory(List)` call. +- New child-window storage requirements must be added to `StorageMethods` and bound in the main registry. + +## 7. Gopeed Backend Migration + +All structured task, configuration, extension, and webhook operations are exposed through `GopeedService`. The local registry currently binds them to `lib/api/api.dart`. + +When the backend moves to FFI: + +- Replace the main-window local bindings or their underlying implementation. +- Keep `GopeedMethods`, `GopeedService`, child-window transport, pages, and controllers unchanged. +- Keep raw HTTP proxy functionality separate unless an explicit capability contract is introduced for it. + +## 8. Adding A Capability + +1. Add one typed `RpcMethod` to the appropriate method catalog. +2. Register a model decoder once if a new non-primitive type crosses the boundary. +3. Add the strongly typed facade method to the capability service. +4. Bind the method to the main-window implementation in `LocalAppCapabilities`. +5. Use the facade from a Riverpod controller or page. +6. Add tests for local typed dispatch and serialized dispatch. +7. For events, add one centralized event name and broadcast a complete snapshot. + +## 9. Prohibited Patterns + +Child-window code must not: + +- Import `lib/api/api.dart`. +- Call `api.init` or access the Gopeed socket/address/token. +- Import or access `Database.instance`. +- Open Hive boxes. +- Receive API configuration or persisted data through `AppWindowPayload`. +- Add feature-specific MethodChannels. +- Parse RPC JSON directly inside a page or widget. + +These constraints keep all backend and persistence ownership in the main window and allow multiple child windows to operate concurrently without database locks or duplicated Gopeed runtimes. diff --git a/ui/flutter/ios/Flutter/AppFrameworkInfo.plist b/ui/flutter/ios/Flutter/AppFrameworkInfo.plist index 9625e105..391a902b 100644 --- a/ui/flutter/ios/Flutter/AppFrameworkInfo.plist +++ b/ui/flutter/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 11.0 diff --git a/ui/flutter/ios/Podfile b/ui/flutter/ios/Podfile index 2f4162c7..8a9751f4 100644 --- a/ui/flutter/ios/Podfile +++ b/ui/flutter/ios/Podfile @@ -1,5 +1,4 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -33,12 +32,19 @@ target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - # share_handler addition start + # The iOS share extension and the Flutter plugin exchange the incoming share + # payload through the models pod supplied by share_handler_ios. target 'ShareExtension' do + # Keep the inherited CocoaPods platform here. The Xcode extension target is + # iOS 14; setting a second platform here makes CocoaPods emit two model + # frameworks with the same product path during archive builds. + inherit! :search_paths + pod 'share_handler_ios_models', :path => '.symlinks/plugins/share_handler_ios/ios/Models' + end + + target 'RunnerTests' do inherit! :search_paths - pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models" end - # share_handler addition end end post_install do |installer| diff --git a/ui/flutter/ios/Podfile.lock b/ui/flutter/ios/Podfile.lock index 2b31cd1a..9e3621f9 100644 --- a/ui/flutter/ios/Podfile.lock +++ b/ui/flutter/ios/Podfile.lock @@ -1,52 +1,160 @@ PODS: - - file_selector_ios (0.0.1): + - app_links (6.4.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) + - flutter_foreground_task (0.0.1): + - Flutter + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) + - OrderedSet (~> 6.0.3) + - flutter_inappwebview_ios/Core (0.0.1): + - Flutter + - OrderedSet (~> 6.0.3) + - flutter_local_notifications (0.0.1): + - Flutter + - install_plugin (2.0.0): + - Flutter + - open_filex (0.0.2): + - Flutter + - OrderedSet (6.0.3) - package_info_plus (0.4.5): - Flutter - - path_provider_ios (0.0.1): + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - share_handler_ios (0.0.14): - Flutter - - permission_handler_apple (9.0.4): + - share_handler_ios/share_handler_ios_models (= 0.0.14) + - share_handler_ios_models + - share_handler_ios/share_handler_ios_models (0.0.14): - Flutter - - shared_preferences_ios (0.0.1): + - share_handler_ios_models + - share_handler_ios_models (0.0.9) + - share_plus (0.0.1): - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) + - app_links (from `.symlinks/plugins/app_links/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - install_plugin (from `.symlinks/plugins/install_plugin/ios`) + - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`) + - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - OrderedSet + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: - file_selector_ios: - :path: ".symlinks/plugins/file_selector_ios/ios" + app_links: + :path: ".symlinks/plugins/app_links/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_foreground_task: + :path: ".symlinks/plugins/flutter_foreground_task/ios" + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + install_plugin: + :path: ".symlinks/plugins/install_plugin/ios" + open_filex: + :path: ".symlinks/plugins/open_filex/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" - shared_preferences_ios: - :path: ".symlinks/plugins/shared_preferences_ios/ios" + share_handler_ios: + :path: ".symlinks/plugins/share_handler_ios/ios" + share_handler_ios_models: + :path: ".symlinks/plugins/share_handler_ios/ios/Models" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - file_selector_ios: 6e9b6d7300556c730f224905726f0a2d5d874e51 - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 - permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce - shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad - url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de + app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9 + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_foreground_task: 21ef182ab0a29a3005cc72cd70e5f45cb7f7f817 + flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + install_plugin: b52f81470a1f37b006607bdf7ffc6031df43fe76 + open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c + share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa -PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 +PODFILE CHECKSUM: 7051f989596e02a313dd9d53c0e20afa508c2ef3 -COCOAPODS: 1.11.3 +COCOAPODS: 1.15.2 diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index 1dc4cb6b..1b878228 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -11,18 +11,21 @@ 0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; }; 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LocationKeepAliveManager.swift */; }; B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */; }; + C164A2AA80F148015630B8E9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */; }; D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D081C25B294826C0006EB10B /* libc++.tbd */; }; D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; }; D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */; }; - A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LocationKeepAliveManager.swift */;}; - A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference;lastKnownFileType = sourcecode.swift;path = LocationKeepAliveManager.swift;sourceTree = "";}; + E7D83A5770AC69B4DCA161EE /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D1CE979011753E6E07933F8 /* Pods_ShareExtension.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -33,6 +36,13 @@ remoteGlobalIDString = 0C585CB62D41E28900FF2EC0; remoteInfo = ShareExtension; }; + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -49,34 +59,43 @@ }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; - buildActionMask = 8; + buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 1; + runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = ""; }; 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 0C585CBE2D41E28900FF2EC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 1D132378263CD94DFAF4FE05 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3217CFC346202B3E2A8C52DD /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78EE8538B1DF04DAF73E75D3 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; + 78F0D48FED63C04B9A3FFDE4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 7A8BE504A2C253C45B760DD5 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8D1CE979011753E6E07933F8 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -84,7 +103,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationKeepAliveManager.swift; sourceTree = ""; }; + BE83526BCA48EE87CB910236 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + BFA76EAE38733F2444C8A609 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = ""; }; + D554135997A5C7A2C62E1B64 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -94,6 +116,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E7D83A5770AC69B4DCA161EE /* Pods_ShareExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,8 +126,16 @@ files = ( D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */, D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */, - DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */, B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */, + C164A2AA80F148015630B8E9 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E284381C0C634B7B17ECA8B1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -122,6 +153,44 @@ path = ShareExtension; sourceTree = ""; }; + 2B6C6F082D376286EF3D5FA3 /* Pods */ = { + isa = PBXGroup; + children = ( + 1D132378263CD94DFAF4FE05 /* Pods-Runner.debug.xcconfig */, + D554135997A5C7A2C62E1B64 /* Pods-Runner.release.xcconfig */, + BE83526BCA48EE87CB910236 /* Pods-Runner.profile.xcconfig */, + 3217CFC346202B3E2A8C52DD /* Pods-RunnerTests.debug.xcconfig */, + 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */, + 78F0D48FED63C04B9A3FFDE4 /* Pods-RunnerTests.profile.xcconfig */, + BFA76EAE38733F2444C8A609 /* Pods-ShareExtension.debug.xcconfig */, + 7A8BE504A2C253C45B760DD5 /* Pods-ShareExtension.release.xcconfig */, + 78EE8538B1DF04DAF73E75D3 /* Pods-ShareExtension.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 44BA5D7C75A3E4957F6560C7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D0E623A729482D030001185E /* Libgopeed.xcframework */, + D081C25B294826C0006EB10B /* libc++.tbd */, + 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */, + 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */, + 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */, + 8D1CE979011753E6E07933F8 /* Pods_ShareExtension.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -140,8 +209,9 @@ 97C146F01CF9000F007C117D /* Runner */, 0C585CB82D41E28900FF2EC0 /* ShareExtension */, 97C146EF1CF9000F007C117D /* Products */, - ADF7F433B4BC23D6C18AB78B /* Pods */, - B4A1CBAC5AA50208793CFF34 /* Frameworks */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 2B6C6F082D376286EF3D5FA3 /* Pods */, + 44BA5D7C75A3E4957F6560C7 /* Frameworks */, ); sourceTree = ""; }; @@ -150,48 +220,29 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */, - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - A10000000000000000000002 /* LocationKeepAliveManager.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); + isa = PBXGroup; + children = ( + 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + A10000000000000000000002 /* LocationKeepAliveManager.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); path = Runner; sourceTree = ""; }; - ADF7F433B4BC23D6C18AB78B /* Pods */ = { - isa = PBXGroup; - children = ( - 97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */, - BC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */, - 25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - B4A1CBAC5AA50208793CFF34 /* Frameworks */ = { - isa = PBXGroup; - children = ( - D0E623A729482D030001185E /* Libgopeed.xcframework */, - D081C25B294826C0006EB10B /* libc++.tbd */, - 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */, - 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -199,6 +250,7 @@ isa = PBXNativeTarget; buildConfigurationList = 0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget "ShareExtension" */; buildPhases = ( + AD5F5DE3738658B0A7E74472 /* [CP] Check Pods Manifest.lock */, 0C585CB32D41E28900FF2EC0 /* Sources */, 0C585CB42D41E28900FF2EC0 /* Frameworks */, 0C585CB52D41E28900FF2EC0 /* Resources */, @@ -212,11 +264,30 @@ productReference = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 59A4F7E149099E3DC8E530B6 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + E284381C0C634B7B17ECA8B1 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - F3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */, + 7604372DDAC1B0765989F84A /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -224,7 +295,7 @@ 0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - DDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */, + FBFC0476FA9528F4D298AD1E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -242,13 +313,17 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 1430; + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 0C585CB62D41E28900FF2EC0 = { CreatedOnToolsVersion = 15.4; }; + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -270,6 +345,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 0C585CB62D41E28900FF2EC0 /* ShareExtension */, + 331C8080294A63A400263BE5 /* RunnerTests */, ); }; /* End PBXProject section */ @@ -283,6 +359,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -313,39 +396,29 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; - DDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */ = { + 59A4F7E149099E3DC8E530B6 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - F3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */ = { + 7604372DDAC1B0765989F84A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -367,6 +440,60 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + AD5F5DE3738658B0A7E74472 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ShareExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FBFC0476FA9528F4D298AD1E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,14 +505,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ @@ -396,6 +532,11 @@ target = 0C585CB62D41E28900FF2EC0 /* ShareExtension */; targetProxy = 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */; }; + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -428,39 +569,25 @@ /* Begin XCBuildConfiguration section */ 0C585CC32D41E28900FF2EC0 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BFA76EAE38733F2444C8A609 /* Pods-ShareExtension.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = JH48DS925K; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -469,37 +596,24 @@ }; 0C585CC42D41E28900FF2EC0 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7A8BE504A2C253C45B760DD5 /* Pods-ShareExtension.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = JH48DS925K; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -507,37 +621,24 @@ }; 0C585CC52D41E28900FF2EC0 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 78EE8538B1DF04DAF73E75D3 /* Pods-ShareExtension.profile.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = JH48DS925K; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -547,6 +648,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -576,6 +678,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -584,7 +687,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -597,12 +700,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = JH48DS925K; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -617,10 +718,61 @@ }; name = Profile; }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3217CFC346202B3E2A8C52DD /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 78F0D48FED63C04B9A3FFDE4 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -650,6 +802,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -664,7 +817,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -676,6 +829,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -705,6 +859,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -713,7 +868,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -728,12 +883,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = JH48DS925K; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -753,12 +906,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = JH48DS925K; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -786,6 +937,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ui/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ui/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a6b826db..e3773d42 100644 --- a/ui/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ui/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ + + + + diff --git a/ui/flutter/ios/Runner/AppDelegate.swift b/ui/flutter/ios/Runner/AppDelegate.swift index cf13bafa..b13f412c 100644 --- a/ui/flutter/ios/Runner/AppDelegate.swift +++ b/ui/flutter/ios/Runner/AppDelegate.swift @@ -1,75 +1,70 @@ -import UIKit import Flutter import Libgopeed +import UIKit -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - let controller : FlutterViewController = window?.rootViewController as! FlutterViewController - let batteryChannel = FlutterMethodChannel(name: "gopeed.com/libgopeed", - binaryMessenger: controller.binaryMessenger) - batteryChannel.setMethodCallHandler({ - (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in - switch call.method { - case "start": - let args = call.arguments as? Dictionary - let cfg = args?["cfg"] as? String - let portPrt = UnsafeMutablePointer.allocate(capacity: MemoryLayout.stride) - var error: NSError? - if LibgopeedStart(cfg, portPrt, &error){ - result(portPrt.pointee) - }else{ - result(FlutterError(code: "ERROR", message: error.debugDescription, details: nil)) - } - case "stop": - LibgopeedStop() - result(nil) - default: - result(FlutterMethodNotImplemented) - } - }) - - let locationChannel = FlutterMethodChannel( - name: "gopeed/location_keep_alive", - binaryMessenger: controller.binaryMessenger -) +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } - locationChannel.setMethodCallHandler { - (call: FlutterMethodCall, result: @escaping FlutterResult) in + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + let messenger = engineBridge.applicationRegistrar.messenger() - switch call.method { - - case "requestPermission": - LocationKeepAliveManager.shared.requestPermission { authorized in - result(authorized) - } - - case "start": - LocationKeepAliveManager.shared.start() - result(nil) - - case "stop": - LocationKeepAliveManager.shared.stop() - result(nil) - - default: - result(FlutterMethodNotImplemented) - } -} - - GeneratedPluginRegistrant.register(with: self) - - SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback(registerPlugins) - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + let libgopeedChannel = FlutterMethodChannel( + name: "gopeed.com/libgopeed", + binaryMessenger: messenger + ) + libgopeedChannel.setMethodCallHandler { call, result in + switch call.method { + case "start": + let arguments = call.arguments as? [String: Any] + let config = arguments?["cfg"] as? String + let port = UnsafeMutablePointer.allocate(capacity: 1) + defer { port.deallocate() } + var error: NSError? + if LibgopeedStart(config, port, &error) { + result(port.pointee) + } else { + result( + FlutterError( + code: "ERROR", + message: error?.localizedDescription ?? "Failed to start Libgopeed", + details: nil + ) + ) } - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + case "stop": + LibgopeedStop() + result(nil) + default: + result(FlutterMethodNotImplemented) + } } -} -func registerPlugins(registry: FlutterPluginRegistry) { - GeneratedPluginRegistrant.register(with: registry) + let locationChannel = FlutterMethodChannel( + name: "gopeed/location_keep_alive", + binaryMessenger: messenger + ) + locationChannel.setMethodCallHandler { call, result in + switch call.method { + case "requestPermission": + LocationKeepAliveManager.shared.requestPermission { authorized in + result(authorized) + } + case "start": + LocationKeepAliveManager.shared.start() + result(nil) + case "stop": + LocationKeepAliveManager.shared.stop() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } } diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fab..d0d98aa1 100644 --- a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index ebb599e9..2b21f5d4 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index bd992b24..090ab5da 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 8000e850..568517b4 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 4388230a..cd5739b9 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 86b1cfd2..b9e38bf3 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 9d5dacad..1cd1b0f7 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index d7ad8951..fbcce4d9 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 8000e850..568517b4 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 327d490d..c8c4e5b8 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 2e3603dd..a007e702 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 3a680a90..f3f28665 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index d4be832e..04375265 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index f3fb629b..6abb238c 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index e63dad43..b0114baf 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 2e3603dd..a007e702 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index a7bbecde..dc67916a 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index a5666c9f..21d4fb30 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 5da20a9d..f29bd2d8 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 4a2479aa..22892b15 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index adb453f2..e9ce1068 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 931b4c25..8516b98c 100644 Binary files a/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index 6e7c20c7..fd2ce83a 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -1,95 +1,83 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Gopeed - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gopeed + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Gopeed + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + - NSAllowsArbitraryLoads - + CFBundleTypeRole + Editor + CFBundleURLName + gopeed + CFBundleURLSchemes + + gopeed + - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - UIApplicationSupportsIndirectInputEvents - - - - - NSUserActivityTypes - - INSendMessageIntent - - - - - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) - - - - - NSPhotoLibraryUsageDescription - Photos can be shared to and used in this app - - - LSSupportsOpeningDocumentsInPlace - No - CFBundleDocumentTypes - - - CFBundleTypeName - ShareHandler - LSHandlerRank - Alternate - LSItemContentTypes + + CFBundleTypeRole + Editor + CFBundleURLName + magnet + CFBundleURLSchemes + + magnet + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + + CFBundleDocumentTypes + + + CFBundleTypeName + BitTorrent Document + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + org.bittorrent.torrent + public.data + + + + CFBundleTypeName + Shared Content + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes public.file-url public.image @@ -98,23 +86,72 @@ public.url public.data - - - - - UIFileSharingEnabled + + + NSUserActivityTypes + + INSendMessageIntent + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads - LSSupportsOpeningDocumentsInPlace - - UIBackgroundModes - - location - - NSLocationWhenInUseUsageDescription - Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. - NSLocationAlwaysAndWhenInUseUsageDescription - Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. - NSLocationAlwaysUsageDescription - Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. - \ No newline at end of file + NSLocationAlwaysAndWhenInUseUsageDescription + 下载任务运行时,Gopeed 使用定位权限帮助应用在后台保持活动。 + NSLocationWhenInUseUsageDescription + 下载任务运行时,Gopeed 使用定位权限帮助应用在后台保持活动。 + NSPhotoLibraryUsageDescription + 允许从照片图库分享文件到 Gopeed。 + UIBackgroundModes + + location + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ui/flutter/ios/Runner/LocationKeepAliveManager.swift b/ui/flutter/ios/Runner/LocationKeepAliveManager.swift index 33f2b047..6d5fadb4 100644 --- a/ui/flutter/ios/Runner/LocationKeepAliveManager.swift +++ b/ui/flutter/ios/Runner/LocationKeepAliveManager.swift @@ -1,126 +1,83 @@ -import Foundation import CoreLocation +import Foundation final class LocationKeepAliveManager: NSObject, CLLocationManagerDelegate { + static let shared = LocationKeepAliveManager() - static let shared = LocationKeepAliveManager() + private let locationManager = CLLocationManager() + private var permissionCompletion: ((Bool) -> Void)? + private(set) var isRunning = false - private let locationManager = CLLocationManager() - - private(set) var isRunning = false - - private var permissionCompletion: ((Bool) -> Void)? - - override init() { - super.init() - - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers - locationManager.distanceFilter = kCLDistanceFilterNone - locationManager.pausesLocationUpdatesAutomatically = false - - if #available(iOS 9.0, *) { - locationManager.allowsBackgroundLocationUpdates = true - } - - if #available(iOS 14.0, *) { - locationManager.showsBackgroundLocationIndicator = true - } + private var authorizationStatus: CLAuthorizationStatus { + if #available(iOS 14.0, *) { + return locationManager.authorizationStatus } + return CLLocationManager.authorizationStatus() + } - func requestPermission(completion: @escaping (Bool) -> Void) { - let status = CLLocationManager.authorizationStatus() - - switch status { - case .authorizedAlways: - completion(true) - - case .authorizedWhenInUse: - completion(false) - - case .denied, .restricted: - completion(false) - - case .notDetermined: - permissionCompletion = completion - locationManager.requestAlwaysAuthorization() - - @unknown default: - completion(false) - } + override init() { + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers + locationManager.distanceFilter = kCLDistanceFilterNone + locationManager.pausesLocationUpdatesAutomatically = false + locationManager.allowsBackgroundLocationUpdates = true + if #available(iOS 14.0, *) { + locationManager.showsBackgroundLocationIndicator = true } + } - - func start() { - guard !isRunning else { - return - } - - let status = CLLocationManager.authorizationStatus() - - guard status == .authorizedAlways else { - return - } - - if #available(iOS 9.0, *) { - locationManager.allowsBackgroundLocationUpdates = true - } - - locationManager.startUpdatingLocation() - isRunning = true + func requestPermission(completion: @escaping (Bool) -> Void) { + switch authorizationStatus { + case .authorizedAlways: + completion(true) + case .authorizedWhenInUse, .denied, .restricted: + completion(false) + case .notDetermined: + permissionCompletion = completion + locationManager.requestAlwaysAuthorization() + @unknown default: + completion(false) } + } - func stop() { - locationManager.stopUpdatingLocation() - isRunning = false + func start() { + guard !isRunning, authorizationStatus == .authorizedAlways else { return } + locationManager.startUpdatingLocation() + isRunning = true + } + + func stop() { + locationManager.stopUpdatingLocation() + isRunning = false + } + + private func handleAuthorization(_ status: CLAuthorizationStatus) { + switch status { + case .authorizedAlways: + permissionCompletion?(true) + permissionCompletion = nil + case .authorizedWhenInUse, .denied, .restricted: + permissionCompletion?(false) + permissionCompletion = nil + case .notDetermined: + break + @unknown default: + permissionCompletion?(false) + permissionCompletion = nil } + } - private func handleAuthorizationStatus( - _ status: CLAuthorizationStatus - ) { - switch status { - case .authorizedAlways: - permissionCompletion?(true) - permissionCompletion = nil + @available(iOS 14.0, *) + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + handleAuthorization(manager.authorizationStatus) + } - case .authorizedWhenInUse, - .denied, - .restricted: - permissionCompletion?(false) - permissionCompletion = nil + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + handleAuthorization(status) + } - case .notDetermined: - break + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {} - @unknown default: - permissionCompletion?(false) - permissionCompletion = nil - } - } - - @available(iOS 14.0, *) - func locationManagerDidChangeAuthorization( - _ manager: CLLocationManager - ) { - handleAuthorizationStatus(manager.authorizationStatus) - } - - func locationManager( - _ manager: CLLocationManager, - didChangeAuthorization status: CLAuthorizationStatus - ) { - handleAuthorizationStatus(status) - } - - func locationManager( - _ manager: CLLocationManager, - didUpdateLocations locations: [CLLocation] - ) { - } - - func locationManager( - _ manager: CLLocationManager, - didFailWithError error: Error - ) { - } -} \ No newline at end of file + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {} +} diff --git a/ui/flutter/ios/Runner/Runner-Bridging-Header.h b/ui/flutter/ios/Runner/Runner-Bridging-Header.h index 10e9f025..308a2a56 100644 --- a/ui/flutter/ios/Runner/Runner-Bridging-Header.h +++ b/ui/flutter/ios/Runner/Runner-Bridging-Header.h @@ -1,2 +1 @@ #import "GeneratedPluginRegistrant.h" -#import diff --git a/ui/flutter/ios/Runner/SceneDelegate.swift b/ui/flutter/ios/Runner/SceneDelegate.swift new file mode 100644 index 00000000..b9ce8ea2 --- /dev/null +++ b/ui/flutter/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ui/flutter/ios/RunnerTests/RunnerTests.swift b/ui/flutter/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/ui/flutter/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ui/flutter/ios/ShareExtension/Base.lproj/MainInterface.storyboard b/ui/flutter/ios/ShareExtension/Base.lproj/MainInterface.storyboard index 286a5089..73809d88 100644 --- a/ui/flutter/ios/ShareExtension/Base.lproj/MainInterface.storyboard +++ b/ui/flutter/ios/ShareExtension/Base.lproj/MainInterface.storyboard @@ -6,7 +6,6 @@ - diff --git a/ui/flutter/ios/ShareExtension/Info.plist b/ui/flutter/ios/ShareExtension/Info.plist index f21d8454..e254126a 100644 --- a/ui/flutter/ios/ShareExtension/Info.plist +++ b/ui/flutter/ios/ShareExtension/Info.plist @@ -1,49 +1,29 @@ + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - NSExtension + NSExtensionAttributes - NSExtensionAttributes - - - IntentsSupported - - INSendMessageIntent - - - NSExtensionActivationRule - - - - SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, - $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" - || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY - $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY - $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY - $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count - > 0 - PHSupportedMediaTypes - - Video - Image - - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.share-services + IntentsSupported + + INSendMessageIntent + + NSExtensionActivationRule + SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count > 0 + PHSupportedMediaTypes + + Video + Image + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services - \ No newline at end of file + + diff --git a/ui/flutter/ios/ShareExtension/ShareViewController.swift b/ui/flutter/ios/ShareExtension/ShareViewController.swift index b1b38efc..1c9e0d42 100644 --- a/ui/flutter/ios/ShareExtension/ShareViewController.swift +++ b/ui/flutter/ios/ShareExtension/ShareViewController.swift @@ -1,3 +1,3 @@ import share_handler_ios_models - -class ShareViewController: ShareHandlerIosViewController {} \ No newline at end of file + +class ShareViewController: ShareHandlerIosViewController {} diff --git a/ui/flutter/l10n.yaml b/ui/flutter/l10n.yaml new file mode 100644 index 00000000..d6a6c7a4 --- /dev/null +++ b/ui/flutter/l10n.yaml @@ -0,0 +1,9 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false +preferred-supported-locales: + - en + - zh +untranslated-messages-file: build/l10n_untranslated.json diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index 80f95147..264516e4 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -3,11 +3,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; -import 'package:get/get.dart' as getx; -import '../app/routes/app_pages.dart'; -import '../database/database.dart'; -import '../util/util.dart'; import 'model/create_task.dart'; import 'model/create_task_batch.dart'; import 'model/downloader_config.dart'; @@ -22,286 +18,267 @@ import 'model/task.dart'; import 'model/update_check_extension_resp.dart'; import 'model/update_extension_settings.dart'; +class ApiTimeoutException implements Exception { + const ApiTimeoutException(this.message); + + final String message; + + @override + String toString() => message; +} + class _Client { - static _Client? _instance; + _Client._(); + + static final _Client instance = _Client._(); late Dio dio; + bool initialized = false; - _Client._internal(); - - factory _Client(String network, String address, String apiToken) { - if (_instance == null) { - _instance = _Client._internal(); - var dio = Dio(); - final isUnixSocket = network == 'unix'; - var baseUrl = 'http://127.0.0.1/'; - if (!isUnixSocket) { - if (Util.isWeb()) { - baseUrl = kDebugMode ? 'http://127.0.0.1:9999/' : ''; - } else { - baseUrl = 'http://$address/'; - } - } - dio.options.baseUrl = baseUrl; - dio.options.contentType = Headers.jsonContentType; - dio.options.sendTimeout = const Duration(seconds: 5); - dio.options.connectTimeout = const Duration(seconds: 5); - dio.options.receiveTimeout = const Duration(seconds: 120); - dio.interceptors.add(InterceptorsWrapper( + void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) { + final isUnixSocket = network == 'unix'; + final baseUrl = isUnixSocket + ? 'http://127.0.0.1/' + : kIsWeb + ? kDebugMode + ? 'http://127.0.0.1:9999/' + : '' + : 'http://$address/'; + dio = Dio( + BaseOptions( + baseUrl: baseUrl, + contentType: Headers.jsonContentType, + sendTimeout: const Duration(seconds: 5), + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 120), + ), + ); + dio.interceptors.add( + InterceptorsWrapper( onRequest: (options, handler) { if (apiToken.isNotEmpty) { options.headers['X-Api-Token'] = apiToken; } - if (Util.isWeb()) { - final token = Database.instance.getWebToken(); - if (token != null) { - options.headers['Authorization'] = 'Bearer $token'; - } - } handler.next(options); }, onError: (error, handler) { - // Only web version has a login page - if (Util.isWeb() && error.response?.statusCode == 401) { - getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN); + if (kIsWeb && error.response?.statusCode == 401) { + onUnauthorized?.call(); } handler.next(error); }, - )); - - _instance!.dio = dio; - if (isUnixSocket) { - (_instance!.dio.httpClientAdapter as IOHttpClientAdapter) - .createHttpClient = () { - final client = HttpClient(); - client.connectionFactory = - (Uri uri, String? proxyHost, int? proxyPort) { - return Socket.startConnect( - InternetAddress(address, type: InternetAddressType.unix), 0); - }; - return client; + ), + ); + if (isUnixSocket) { + (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { + final client = HttpClient(); + client.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) { + return Socket.startConnect(InternetAddress(address, type: InternetAddressType.unix), 0); }; - } + return client; + }; } - return _instance!; + initialized = true; } } -class TimeoutException implements Exception { - final String message; - - TimeoutException(this.message); +void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) { + _Client.instance.init(network, address, apiToken, onUnauthorized: onUnauthorized); } -late _Client _client; - -void init(String network, String address, String apiToken) { - _client = _Client(network, address, apiToken); +void initDefault({String address = '127.0.0.1:9999', String apiToken = ''}) { + if (!_Client.instance.initialized) { + init('tcp', address, apiToken); + } } -/// Used by LocationKeepAliveCoordinator to reconcile keep-alive -void Function()? _onTaskChanged; - -void setTaskChangedListener(void Function() listener) { - _onTaskChanged = listener; -} - -void _notifyTaskChanged() { - _onTaskChanged?.call(); -} - -Future _parse( - Future Function() fetch, - T Function(dynamic json)? fromJsonT, -) async { +Future _parse(Future> Function() fetch, T Function(dynamic json)? fromJsonT) async { + initDefault(); try { - var resp = await fetch(); + final resp = await fetch(); fromJsonT ??= (json) => null as T; final result = Result.fromJson(resp.data, fromJsonT); if (result.code == 0) { return result.data as T; - } else { - throw Exception(result); } + final message = result.msg; + throw Exception(message == null || message.isEmpty ? 'API error ${result.code}' : message); } on DioException catch (e) { if (e.type == DioExceptionType.sendTimeout || e.type == DioExceptionType.receiveTimeout || e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError) { - throw TimeoutException("request timeout"); + throw const ApiTimeoutException('request timeout'); } - throw Exception(Result(code: 1000, msg: e.message)); + throw Exception(e.message ?? 'request failed'); } } -Future resolve(ResolveTask resolveTask) async { +Future resolve(ResolveTask resolveTask) { + if (resolveTask.req == null) { + throw ArgumentError('resolve request is required'); + } return _parse( - () => _client.dio.post("api/v1/resolve", data: resolveTask), - (data) => ResolveResult.fromJson(data)); + () => _Client.instance.dio.post('api/v1/resolve', data: resolveTask), + (data) => ResolveResult.fromJson(data as Map), + ); } -Future createTask(CreateTask createTask) async { - final result = await _parse( - () => _client.dio.post("api/v1/tasks", data: createTask), - (data) => data as String); - _notifyTaskChanged(); - return result; +Future createTask(CreateTask createTask) { + return _parse(() => _Client.instance.dio.post('api/v1/tasks', data: createTask), (data) => data as String); } -Future> createTaskBatch(CreateTaskBatch createTaskBatch) async { - final result = await _parse>( - () => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch), - (data) => (data as List).map((e) => e as String).toList()); - _notifyTaskChanged(); - return result; +Future> createTaskBatch(CreateTaskBatch createTaskBatch) { + return _parse>( + () => _Client.instance.dio.post('api/v1/tasks/batch', data: createTaskBatch), + (data) => (data as List).map((e) => e as String).toList(), + ); } -Future patchTask(String id, ResolveTask patchTask) async { - return _parse( - () => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null); +Future patchTask(String id, ResolveTask patchTask) { + return _parse(() => _Client.instance.dio.patch('api/v1/tasks/$id', data: patchTask), null); } -Future> getTasks(List statuses) async { +Future> getTasks(List statuses) { + final query = statuses.map((e) => 'status=${e.name}').join('&'); return _parse>( - () => _client.dio.get( - "/api/v1/tasks?${statuses.map((e) => "status=${e.name}").join("&")}"), - (data) => (data as List).map((e) => Task.fromJson(e)).toList()); + () => _Client.instance.dio.get('/api/v1/tasks?$query'), + (data) => (data as List).map((e) => Task.fromJson(e as Map)).toList(), + ); } -Future pauseTask(String id) async { - await _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null); - _notifyTaskChanged(); +Future getTaskStatus(String id) { + return _parse( + () => _Client.instance.dio.get('/api/v1/tasks/$id/status'), + (data) => TaskRuntimeStatus.fromJson(data as Map), + ); } -Future continueTask(String id) async { - await _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null); - _notifyTaskChanged(); +Future> getTaskStats(String id) { + return _parse>( + () => _Client.instance.dio.get('/api/v1/tasks/$id/stats'), + (data) => + data is Map ? {for (final entry in data.entries) entry.key.toString(): entry.value} : const {}, + ); } -Future pauseAllTasks(List? ids) async { - await _parse( - () => _client.dio.put("api/v1/tasks/pause", queryParameters: { - "id": ids, - }), - null); - _notifyTaskChanged(); +Future pauseTask(String id) { + return _parse(() => _Client.instance.dio.put('api/v1/tasks/$id/pause'), null); } -Future continueAllTasks(List? ids) async { - await _parse( - () => _client.dio.put("api/v1/tasks/continue", queryParameters: { - "id": ids, - }), - null); - _notifyTaskChanged(); +Future continueTask(String id) { + return _parse(() => _Client.instance.dio.put('api/v1/tasks/$id/continue'), null); } -Future deleteTask(String id, bool force) async { - await _parse( - () => _client.dio.delete("api/v1/tasks/$id?force=$force"), null); - _notifyTaskChanged(); +Future pauseAllTasks(List? ids) { + return _parse(() => _Client.instance.dio.put('api/v1/tasks/pause', queryParameters: {'id': ids}), null); } -Future deleteTasks(List? ids, bool force) async { - await _parse( - () => _client.dio.delete("api/v1/tasks", queryParameters: { - "id": ids, - "force": force, - }), - null); - _notifyTaskChanged(); +Future continueAllTasks(List? ids) { + return _parse(() => _Client.instance.dio.put('api/v1/tasks/continue', queryParameters: {'id': ids}), null); } -Future getConfig() async { - return _parse(() => _client.dio.get("api/v1/config"), - (data) => DownloaderConfig.fromJson(data)); +Future deleteTask(String id, bool force) { + return _parse(() => _Client.instance.dio.delete('api/v1/tasks/$id?force=$force'), null); } -Future putConfig(DownloaderConfig config) async { - return _parse(() => _client.dio.put("api/v1/config", data: config), null); +Future deleteTasks(List? ids, bool force) { + return _parse( + () => _Client.instance.dio.delete('api/v1/tasks', queryParameters: {'id': ids, 'force': force}), + null, + ); } -Future installExtension(InstallExtension installExtension) async { +Future getConfig() { + return _parse( + () => _Client.instance.dio.get('api/v1/config'), + (data) => DownloaderConfig.fromJson(data as Map), + ); +} + +Future putConfig(DownloaderConfig config) { + return _parse(() => _Client.instance.dio.put('api/v1/config', data: config.toJson()), null); +} + +Future installExtension(InstallExtension installExtension) { return _parse( - () => _client.dio.post("api/v1/extensions", data: installExtension), - (data) => data as String); + () => _Client.instance.dio.post('api/v1/extensions', data: installExtension.toJson()), + (data) => data as String, + ); } -Future> getExtensions() async { - return _parse>(() => _client.dio.get("api/v1/extensions"), - (data) => (data as List).map((e) => Extension.fromJson(e)).toList()); +Future> getExtensions() { + return _parse>( + () => _Client.instance.dio.get('api/v1/extensions'), + (data) => (data as List).map((e) => Extension.fromJson(e as Map)).toList(), + ); } -Future updateExtensionSettings( - String identity, UpdateExtensionSettings updateExtensionSettings) async { - return _parse( - () => _client.dio.put("api/v1/extensions/$identity/settings", - data: updateExtensionSettings), - null); +Future updateExtensionSettings(String identity, UpdateExtensionSettings updateExtensionSettings) { + return _parse( + () => _Client.instance.dio.put('api/v1/extensions/$identity/settings', data: updateExtensionSettings.toJson()), + null, + ); } -Future switchExtension( - String identity, SwitchExtension switchExtension) async { - return _parse( - () => _client.dio - .put("api/v1/extensions/$identity/switch", data: switchExtension), - null); +Future switchExtension(String identity, SwitchExtension switchExtension) { + return _parse( + () => _Client.instance.dio.put('api/v1/extensions/$identity/switch', data: switchExtension.toJson()), + null, + ); } -Future deleteExtension(String identity) async { - return _parse(() => _client.dio.delete("api/v1/extensions/$identity"), null); +Future deleteExtension(String identity) { + return _parse(() => _Client.instance.dio.delete('api/v1/extensions/$identity'), null); } -Future upgradeCheckExtension(String identity) async { - return _parse(() => _client.dio.get("api/v1/extensions/$identity/update"), - (data) => UpdateCheckExtensionResp.fromJson(data)); +Future upgradeCheckExtension(String identity) { + return _parse( + () => _Client.instance.dio.get('api/v1/extensions/$identity/update'), + (data) => UpdateCheckExtensionResp.fromJson(data as Map), + ); } -Future updateExtension(String identity) async { - return _parse( - () => _client.dio.post("api/v1/extensions/$identity/update"), null); +Future updateExtension(String identity) { + return _parse(() => _Client.instance.dio.post('api/v1/extensions/$identity/update'), null); } -Future testWebhook(String url) async { - return _parse( - () => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null); +Future testWebhook(String url) { + return _parse(() => _Client.instance.dio.post('api/v1/webhook/test', data: {'url': url}), null); } -Future login(LoginReq loginReq) async { - return _parse(() => _client.dio.post("api/web/login", data: loginReq), - (data) => data as String); +Future login(LoginReq loginReq) { + return _parse(() => _Client.instance.dio.post('api/web/login', data: loginReq.toJson()), null); } -Future> proxyRequest(String uri, - {data, Options? options}) async { +Future> proxyRequest(String uri, {dynamic data, Options? options}) async { + initDefault(); options ??= Options(); options.headers ??= {}; - options.headers!["X-Target-Uri"] = uri; - - // add timestamp to avoid cache - return _client.dio.request( - "/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}", - data: data, - options: options); + options.headers!['X-Target-Uri'] = uri; + return _Client.instance.dio.request( + '/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}', + data: data, + options: options, + ); } String join(String path) { - final baseUrl = _client.dio.options.baseUrl; - final cleanBaseUrl = baseUrl.endsWith('/') - ? baseUrl.substring(0, baseUrl.length - 1) - : baseUrl; - return "$cleanBaseUrl/${Util.cleanPath(path)}"; + initDefault(); + final baseUrl = _Client.instance.dio.options.baseUrl; + final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + return '$cleanBaseUrl/$cleanPath'; } -/// Generic request method for API proxy -/// Directly forwards requests to gopeed REST API -Future forward( +Future> forward( String path, { String method = 'GET', dynamic data, Map? queryParameters, }) async { - return _client.dio.request( + initDefault(); + return _Client.instance.dio.request( path, data: data, queryParameters: queryParameters, diff --git a/ui/flutter/lib/api/gopeed_site_api.dart b/ui/flutter/lib/api/gopeed_site_api.dart index 4e4e2e30..e90dedda 100644 --- a/ui/flutter/lib/api/gopeed_site_api.dart +++ b/ui/flutter/lib/api/gopeed_site_api.dart @@ -12,9 +12,12 @@ class GopeedSiteApi { static const _host = 'gopeed.com'; - Future> getRelease() async { - final json = await _getJson('/api/release'); - return json as Map; + Future> getReleases({int perPage = 10}) async { + final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()}); + if (json is! List) { + throw const FormatException('Invalid releases response'); + } + return json; } Future getExtensions({ @@ -24,13 +27,16 @@ class GopeedSiteApi { StoreSortOrder order = StoreSortOrder.desc, String? query, }) async { - final json = await _getJson('/api/extensions', queryParameters: { - 'page': page.toString(), - 'limit': limit.clamp(1, 100).toString(), - 'sort': sort.name, - 'order': order.name, - if (query != null && query.trim().isNotEmpty) 'q': query.trim(), - }); + final json = await _getJson( + '/api/extensions', + queryParameters: { + 'page': page.toString(), + 'limit': limit.clamp(1, 100).toString(), + 'sort': sort.name, + 'order': order.name, + if (query != null && query.trim().isNotEmpty) 'q': query.trim(), + }, + ); return StoreExtensionPage.fromJson(json as Map); } @@ -43,8 +49,7 @@ class GopeedSiteApi { ); } - Future _getJson(String path, - {Map? queryParameters}) async { + Future _getJson(String path, {Map? queryParameters}) async { final uri = Uri.https(_host, path, queryParameters); final Response response = await proxyRequest(uri.toString()); if (response.data == null || response.data!.isEmpty) { diff --git a/ui/flutter/lib/api/model/create_task.dart b/ui/flutter/lib/api/model/create_task.dart index 5ddbe999..ecbac630 100644 --- a/ui/flutter/lib/api/model/create_task.dart +++ b/ui/flutter/lib/api/model/create_task.dart @@ -11,16 +11,9 @@ class CreateTask { Request? req; Options? opts; - CreateTask({ - this.rid, - this.req, - this.opts, - }); + CreateTask({this.rid, this.req, this.opts}); - factory CreateTask.fromJson( - Map json, - ) => - _$CreateTaskFromJson(json); + factory CreateTask.fromJson(Map json) => _$CreateTaskFromJson(json); Map toJson() => _$CreateTaskToJson(this); } diff --git a/ui/flutter/lib/api/model/create_task.g.dart b/ui/flutter/lib/api/model/create_task.g.dart index f36e9432..4d9ab219 100644 --- a/ui/flutter/lib/api/model/create_task.g.dart +++ b/ui/flutter/lib/api/model/create_task.g.dart @@ -7,26 +7,13 @@ part of 'create_task.dart'; // ************************************************************************** CreateTask _$CreateTaskFromJson(Map json) => CreateTask( - rid: json['rid'] as String?, - req: json['req'] == null - ? null - : Request.fromJson(json['req'] as Map), - opts: json['opts'] == null - ? null - : Options.fromJson(json['opts'] as Map), - ); + rid: json['rid'] as String?, + req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), + opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), +); -Map _$CreateTaskToJson(CreateTask instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('rid', instance.rid); - writeNotNull('req', instance.req?.toJson()); - writeNotNull('opts', instance.opts?.toJson()); - return val; -} +Map _$CreateTaskToJson(CreateTask instance) => { + 'rid': ?instance.rid, + 'req': ?instance.req?.toJson(), + 'opts': ?instance.opts?.toJson(), +}; diff --git a/ui/flutter/lib/api/model/create_task_batch.dart b/ui/flutter/lib/api/model/create_task_batch.dart index 470c5465..4e12a4ce 100644 --- a/ui/flutter/lib/api/model/create_task_batch.dart +++ b/ui/flutter/lib/api/model/create_task_batch.dart @@ -10,15 +10,9 @@ class CreateTaskBatch { List? reqs; Options? opts; - CreateTaskBatch({ - this.reqs, - this.opts, - }); + CreateTaskBatch({this.reqs, this.opts}); - factory CreateTaskBatch.fromJson( - Map json, - ) => - _$CreateTaskBatchFromJson(json); + factory CreateTaskBatch.fromJson(Map json) => _$CreateTaskBatchFromJson(json); Map toJson() => _$CreateTaskBatchToJson(this); } @@ -28,12 +22,8 @@ class CreateTaskBatchItem { Request? req; Options? opts; - CreateTaskBatchItem({ - this.req, - this.opts, - }); + CreateTaskBatchItem({this.req, this.opts}); - factory CreateTaskBatchItem.fromJson(Map json) => - _$CreateTaskBatchItemFromJson(json); + factory CreateTaskBatchItem.fromJson(Map json) => _$CreateTaskBatchItemFromJson(json); Map toJson() => _$CreateTaskBatchItemToJson(this); } diff --git a/ui/flutter/lib/api/model/create_task_batch.g.dart b/ui/flutter/lib/api/model/create_task_batch.g.dart index cf01d8fb..3b1b07a1 100644 --- a/ui/flutter/lib/api/model/create_task_batch.g.dart +++ b/ui/flutter/lib/api/model/create_task_batch.g.dart @@ -6,50 +6,22 @@ part of 'create_task_batch.dart'; // JsonSerializableGenerator // ************************************************************************** -CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => - CreateTaskBatch( - reqs: (json['reqs'] as List?) - ?.map((e) => CreateTaskBatchItem.fromJson(e as Map)) - .toList(), - opts: json['opts'] == null - ? null - : Options.fromJson(json['opts'] as Map), - ); +CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => CreateTaskBatch( + reqs: (json['reqs'] as List?)?.map((e) => CreateTaskBatchItem.fromJson(e as Map)).toList(), + opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), +); -Map _$CreateTaskBatchToJson(CreateTaskBatch instance) { - final val = {}; +Map _$CreateTaskBatchToJson(CreateTaskBatch instance) => { + 'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(), + 'opts': ?instance.opts?.toJson(), +}; - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } +CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map json) => CreateTaskBatchItem( + req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), + opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), +); - writeNotNull('reqs', instance.reqs?.map((e) => e.toJson()).toList()); - writeNotNull('opts', instance.opts?.toJson()); - return val; -} - -CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map json) => - CreateTaskBatchItem( - req: json['req'] == null - ? null - : Request.fromJson(json['req'] as Map), - opts: json['opts'] == null - ? null - : Options.fromJson(json['opts'] as Map), - ); - -Map _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('req', instance.req?.toJson()); - writeNotNull('opts', instance.opts?.toJson()); - return val; -} +Map _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) => { + 'req': ?instance.req, + 'opts': ?instance.opts, +}; diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index f0769702..b0b9522b 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -13,16 +13,17 @@ class DownloaderConfig { ScriptConfig script = ScriptConfig(); AutoTorrentConfig autoTorrent = AutoTorrentConfig(); ArchiveConfig archive = ArchiveConfig(); + bool autoStartTasks; bool autoDeleteMissingFileTasks; DownloaderConfig({ this.downloadDir = '', this.maxRunning = 0, + this.autoStartTasks = false, this.autoDeleteMissingFileTasks = false, }); - factory DownloaderConfig.fromJson(Map json) => - _$DownloaderConfigFromJson(json); + factory DownloaderConfig.fromJson(Map json) => _$DownloaderConfigFromJson(json); Map toJson() => _$DownloaderConfigToJson(this); } @@ -47,14 +48,9 @@ class HttpConfig { int connections; bool useServerCtime; - HttpConfig({ - this.userAgent = '', - this.connections = 0, - this.useServerCtime = false, - }); + HttpConfig({this.userAgent = '', this.connections = 0, this.useServerCtime = false}); - factory HttpConfig.fromJson(Map json) => - _$HttpConfigFromJson(json); + factory HttpConfig.fromJson(Map json) => _$HttpConfigFromJson(json); Map toJson() => _$HttpConfigToJson(this); } @@ -75,8 +71,7 @@ class BtConfig { this.seedTime = 0, }); - factory BtConfig.fromJson(Map json) => - _$BtConfigFromJson(json); + factory BtConfig.fromJson(Map json) => _$BtConfigFromJson(json); Map toJson() => _$BtConfigToJson(this); } @@ -89,16 +84,9 @@ class Ed2kConfig { String serverMet; String nodesDat; - Ed2kConfig({ - this.listenPort = 0, - this.udpPort = 0, - this.serverAddr = '', - this.serverMet = '', - this.nodesDat = '', - }); + Ed2kConfig({this.listenPort = 0, this.udpPort = 0, this.serverAddr = '', this.serverMet = '', this.nodesDat = ''}); - factory Ed2kConfig.fromJson(Map json) => - _$Ed2kConfigFromJson(json); + factory Ed2kConfig.fromJson(Map json) => _$Ed2kConfigFromJson(json); Map toJson() => _$Ed2kConfigToJson(this); } @@ -106,12 +94,12 @@ class Ed2kConfig { @JsonSerializable(explicitToJson: true) class ExtraConfig { String themeMode; + String themeColor; String locale; bool lastDeleteTaskKeep; bool defaultDirectDownload; bool defaultBtClient; bool notifyWhenNewVersion; - bool autoStartTasks; bool desktopNotification; bool backgroundLocationKeepAlive; List downloadCategories; @@ -121,12 +109,12 @@ class ExtraConfig { ExtraConfig({ this.themeMode = '', + this.themeColor = 'green', this.locale = '', this.lastDeleteTaskKeep = false, this.defaultDirectDownload = false, this.defaultBtClient = true, this.notifyWhenNewVersion = true, - this.autoStartTasks = false, this.desktopNotification = true, this.backgroundLocationKeepAlive = false, this.downloadCategories = const [], @@ -144,8 +132,7 @@ class DownloadCategory { String path; bool isBuiltIn; String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic') - bool - isDeleted; // Mark built-in categories as deleted instead of removing them + bool isDeleted; // Mark built-in categories as deleted instead of removing them DownloadCategory({ required this.name, @@ -155,8 +142,7 @@ class DownloadCategory { this.isDeleted = false, }); - factory DownloadCategory.fromJson(Map json) => - _$DownloadCategoryFromJson(json); + factory DownloadCategory.fromJson(Map json) => _$DownloadCategoryFromJson(json); Map toJson() => _$DownloadCategoryToJson(this); } @@ -166,10 +152,7 @@ class WebhookConfig { bool enable; List urls; - WebhookConfig({ - this.enable = false, - this.urls = const [], - }); + WebhookConfig({this.enable = false, this.urls = const []}); factory WebhookConfig.fromJson(Map? json) => json == null ? WebhookConfig() : _$WebhookConfigFromJson(json); @@ -182,10 +165,7 @@ class ScriptConfig { bool enable; List paths; - ScriptConfig({ - this.enable = false, - this.paths = const [], - }); + ScriptConfig({this.enable = false, this.paths = const []}); factory ScriptConfig.fromJson(Map? json) => json == null ? ScriptConfig() : _$ScriptConfigFromJson(json); @@ -211,8 +191,7 @@ class ProxyConfig { this.pwd = '', }); - factory ProxyConfig.fromJson(Map json) => - _$ProxyConfigFromJson(json); + factory ProxyConfig.fromJson(Map json) => _$ProxyConfigFromJson(json); Map toJson() => _$ProxyConfigToJson(this); } @@ -228,16 +207,12 @@ class ExtraConfigBt { ExtraConfigBt(); - factory ExtraConfigBt.fromJson(Map json) => - _$ExtraConfigBtFromJson(json); + factory ExtraConfigBt.fromJson(Map json) => _$ExtraConfigBtFromJson(json); Map toJson() => _$ExtraConfigBtToJson(this); } -enum GithubMirrorType { - jsdelivr, - ghProxy, -} +enum GithubMirrorType { jsdelivr, ghProxy } @JsonSerializable() class GithubMirror { @@ -246,15 +221,9 @@ class GithubMirror { bool isBuiltIn; bool isDeleted; - GithubMirror({ - required this.type, - required this.url, - this.isBuiltIn = false, - this.isDeleted = false, - }); + GithubMirror({required this.type, required this.url, this.isBuiltIn = false, this.isDeleted = false}); - factory GithubMirror.fromJson(Map json) => - _$GithubMirrorFromJson(json); + factory GithubMirror.fromJson(Map json) => _$GithubMirrorFromJson(json); Map toJson() => _$GithubMirrorToJson(this); } @@ -264,15 +233,10 @@ class ExtraConfigGithubMirror { bool enabled; List mirrors; - ExtraConfigGithubMirror({ - this.enabled = true, - this.mirrors = const [], - }); + ExtraConfigGithubMirror({this.enabled = true, this.mirrors = const []}); factory ExtraConfigGithubMirror.fromJson(Map? json) => - json == null - ? ExtraConfigGithubMirror() - : _$ExtraConfigGithubMirrorFromJson(json); + json == null ? ExtraConfigGithubMirror() : _$ExtraConfigGithubMirrorFromJson(json); Map toJson() => _$ExtraConfigGithubMirrorToJson(this); } @@ -282,10 +246,7 @@ class AutoTorrentConfig { bool enable; bool deleteAfterDownload; - AutoTorrentConfig({ - this.enable = false, - this.deleteAfterDownload = false, - }); + AutoTorrentConfig({this.enable = false, this.deleteAfterDownload = false}); factory AutoTorrentConfig.fromJson(Map? json) => json == null ? AutoTorrentConfig() : _$AutoTorrentConfigFromJson(json); @@ -298,10 +259,7 @@ class ArchiveConfig { bool autoExtract; bool deleteAfterExtract; - ArchiveConfig({ - this.autoExtract = true, - this.deleteAfterExtract = true, - }); + ArchiveConfig({this.autoExtract = true, this.deleteAfterExtract = true}); factory ArchiveConfig.fromJson(Map? json) => json == null ? ArchiveConfig() : _$ArchiveConfigFromJson(json); diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 9ed7a55b..20e06220 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -8,302 +8,238 @@ part of 'downloader_config.dart'; DownloaderConfig _$DownloaderConfigFromJson(Map json) => DownloaderConfig( - downloadDir: json['downloadDir'] as String? ?? '', - maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0, - autoDeleteMissingFileTasks: - json['autoDeleteMissingFileTasks'] as bool? ?? false, - ) - ..protocolConfig = ProtocolConfig.fromJson( - json['protocolConfig'] as Map?) + downloadDir: json['downloadDir'] as String? ?? '', + maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0, + autoStartTasks: json['autoStartTasks'] as bool? ?? false, + autoDeleteMissingFileTasks: json['autoDeleteMissingFileTasks'] as bool? ?? false, + ) + ..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map?) ..extra = ExtraConfig.fromJson(json['extra'] as Map?) ..proxy = ProxyConfig.fromJson(json['proxy'] as Map) - ..webhook = - WebhookConfig.fromJson(json['webhook'] as Map?) + ..webhook = WebhookConfig.fromJson(json['webhook'] as Map?) ..script = ScriptConfig.fromJson(json['script'] as Map?) - ..autoTorrent = AutoTorrentConfig.fromJson( - json['autoTorrent'] as Map?) - ..archive = - ArchiveConfig.fromJson(json['archive'] as Map?); + ..autoTorrent = AutoTorrentConfig.fromJson(json['autoTorrent'] as Map?) + ..archive = ArchiveConfig.fromJson(json['archive'] as Map?); -Map _$DownloaderConfigToJson(DownloaderConfig instance) => - { - 'downloadDir': instance.downloadDir, - 'maxRunning': instance.maxRunning, - 'protocolConfig': instance.protocolConfig.toJson(), - 'extra': instance.extra.toJson(), - 'proxy': instance.proxy.toJson(), - 'webhook': instance.webhook.toJson(), - 'script': instance.script.toJson(), - 'autoTorrent': instance.autoTorrent.toJson(), - 'archive': instance.archive.toJson(), - 'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks, - }; - -ProtocolConfig _$ProtocolConfigFromJson(Map json) => - ProtocolConfig() - ..http = HttpConfig.fromJson(json['http'] as Map) - ..bt = BtConfig.fromJson(json['bt'] as Map) - ..ed2k = Ed2kConfig.fromJson( - json['ed2k'] as Map? ?? {}); - -Map _$ProtocolConfigToJson(ProtocolConfig instance) => - { - 'http': instance.http.toJson(), - 'bt': instance.bt.toJson(), - 'ed2k': instance.ed2k.toJson(), - }; - -HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( - userAgent: json['userAgent'] as String? ?? '', - connections: (json['connections'] as num?)?.toInt() ?? 0, - useServerCtime: json['useServerCtime'] as bool? ?? false, - ); - -Map _$HttpConfigToJson(HttpConfig instance) => - { - 'userAgent': instance.userAgent, - 'connections': instance.connections, - 'useServerCtime': instance.useServerCtime, - }; - -BtConfig _$BtConfigFromJson(Map json) => BtConfig( - listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, - trackers: (json['trackers'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], - seedKeep: json['seedKeep'] as bool? ?? false, - seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, - seedTime: (json['seedTime'] as num?)?.toInt() ?? 0, - ); - -Map _$BtConfigToJson(BtConfig instance) => { - 'listenPort': instance.listenPort, - 'trackers': instance.trackers, - 'seedKeep': instance.seedKeep, - 'seedRatio': instance.seedRatio, - 'seedTime': instance.seedTime, - }; - -Ed2kConfig _$Ed2kConfigFromJson(Map json) => Ed2kConfig( - listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, - udpPort: (json['udpPort'] as num?)?.toInt() ?? 0, - serverAddr: json['serverAddr'] as String? ?? '', - serverMet: json['serverMet'] as String? ?? '', - nodesDat: json['nodesDat'] as String? ?? '', - ); - -Map _$Ed2kConfigToJson(Ed2kConfig instance) => - { - 'listenPort': instance.listenPort, - 'udpPort': instance.udpPort, - 'serverAddr': instance.serverAddr, - 'serverMet': instance.serverMet, - 'nodesDat': instance.nodesDat, - }; - -ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( - themeMode: json['themeMode'] as String? ?? '', - locale: json['locale'] as String? ?? '', - lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false, - defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false, - defaultBtClient: json['defaultBtClient'] as bool? ?? true, - notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, - autoStartTasks: json['autoStartTasks'] as bool? ?? false, - desktopNotification: json['desktopNotification'] as bool? ?? true, - backgroundLocationKeepAlive: - json['backgroundLocationKeepAlive'] as bool? ?? false, - downloadCategories: (json['downloadCategories'] as List?) - ?.map((e) => DownloadCategory.fromJson(e as Map)) - .toList() ?? - const [], - ) - ..bt = ExtraConfigBt.fromJson(json['bt'] as Map) - ..githubMirror = ExtraConfigGithubMirror.fromJson( - json['githubMirror'] as Map?); - -Map _$ExtraConfigToJson(ExtraConfig instance) => - { - 'themeMode': instance.themeMode, - 'locale': instance.locale, - 'lastDeleteTaskKeep': instance.lastDeleteTaskKeep, - 'defaultDirectDownload': instance.defaultDirectDownload, - 'defaultBtClient': instance.defaultBtClient, - 'notifyWhenNewVersion': instance.notifyWhenNewVersion, - 'autoStartTasks': instance.autoStartTasks, - 'desktopNotification': instance.desktopNotification, - 'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive, - 'downloadCategories': - instance.downloadCategories.map((e) => e.toJson()).toList(), - 'bt': instance.bt.toJson(), - 'githubMirror': instance.githubMirror.toJson(), - }; - -DownloadCategory _$DownloadCategoryFromJson(Map json) => - DownloadCategory( - name: json['name'] as String, - path: json['path'] as String, - isBuiltIn: json['isBuiltIn'] as bool? ?? false, - nameKey: json['nameKey'] as String?, - isDeleted: json['isDeleted'] as bool? ?? false, - ); - -Map _$DownloadCategoryToJson(DownloadCategory instance) { - final val = { - 'name': instance.name, - 'path': instance.path, - 'isBuiltIn': instance.isBuiltIn, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('nameKey', instance.nameKey); - val['isDeleted'] = instance.isDeleted; - return val; -} - -WebhookConfig _$WebhookConfigFromJson(Map json) => - WebhookConfig( - enable: json['enable'] as bool? ?? false, - urls: - (json['urls'] as List?)?.map((e) => e as String).toList() ?? - const [], - ); - -Map _$WebhookConfigToJson(WebhookConfig instance) => - { - 'enable': instance.enable, - 'urls': instance.urls, - }; - -ScriptConfig _$ScriptConfigFromJson(Map json) => ScriptConfig( - enable: json['enable'] as bool? ?? false, - paths: - (json['paths'] as List?)?.map((e) => e as String).toList() ?? - const [], - ); - -Map _$ScriptConfigToJson(ScriptConfig instance) => - { - 'enable': instance.enable, - 'paths': instance.paths, - }; - -ProxyConfig _$ProxyConfigFromJson(Map json) => ProxyConfig( - enable: json['enable'] as bool? ?? false, - system: json['system'] as bool? ?? false, - scheme: json['scheme'] as String? ?? '', - host: json['host'] as String? ?? '', - usr: json['usr'] as String? ?? '', - pwd: json['pwd'] as String? ?? '', - ); - -Map _$ProxyConfigToJson(ProxyConfig instance) => - { - 'enable': instance.enable, - 'system': instance.system, - 'scheme': instance.scheme, - 'host': instance.host, - 'usr': instance.usr, - 'pwd': instance.pwd, - }; - -ExtraConfigBt _$ExtraConfigBtFromJson(Map json) => - ExtraConfigBt() - ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List) - .map((e) => e as String) - .toList() - ..subscribeTrackers = (json['subscribeTrackers'] as List) - .map((e) => e as String) - .toList() - ..autoUpdateTrackers = json['autoUpdateTrackers'] as bool - ..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null - ? null - : DateTime.parse(json['lastTrackerUpdateTime'] as String) - ..customTrackers = (json['customTrackers'] as List) - .map((e) => e as String) - .toList(); - -Map _$ExtraConfigBtToJson(ExtraConfigBt instance) { - final val = { - 'trackerSubscribeUrls': instance.trackerSubscribeUrls, - 'subscribeTrackers': instance.subscribeTrackers, - 'autoUpdateTrackers': instance.autoUpdateTrackers, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('lastTrackerUpdateTime', - instance.lastTrackerUpdateTime?.toIso8601String()); - val['customTrackers'] = instance.customTrackers; - return val; -} - -GithubMirror _$GithubMirrorFromJson(Map json) => GithubMirror( - type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']), - url: json['url'] as String, - isBuiltIn: json['isBuiltIn'] as bool? ?? false, - isDeleted: json['isDeleted'] as bool? ?? false, - ); - -Map _$GithubMirrorToJson(GithubMirror instance) => - { - 'type': _$GithubMirrorTypeEnumMap[instance.type]!, - 'url': instance.url, - 'isBuiltIn': instance.isBuiltIn, - 'isDeleted': instance.isDeleted, - }; - -const _$GithubMirrorTypeEnumMap = { - GithubMirrorType.jsdelivr: 'jsdelivr', - GithubMirrorType.ghProxy: 'ghProxy', +Map _$DownloaderConfigToJson(DownloaderConfig instance) => { + 'downloadDir': instance.downloadDir, + 'maxRunning': instance.maxRunning, + 'protocolConfig': instance.protocolConfig.toJson(), + 'extra': instance.extra.toJson(), + 'proxy': instance.proxy.toJson(), + 'webhook': instance.webhook.toJson(), + 'script': instance.script.toJson(), + 'autoTorrent': instance.autoTorrent.toJson(), + 'archive': instance.archive.toJson(), + 'autoStartTasks': instance.autoStartTasks, + 'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks, }; -ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson( - Map json) => - ExtraConfigGithubMirror( - enabled: json['enabled'] as bool? ?? true, - mirrors: (json['mirrors'] as List?) - ?.map((e) => GithubMirror.fromJson(e as Map)) - .toList() ?? - const [], - ); +ProtocolConfig _$ProtocolConfigFromJson(Map json) => ProtocolConfig() + ..http = HttpConfig.fromJson(json['http'] as Map) + ..bt = BtConfig.fromJson(json['bt'] as Map) + ..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map); -Map _$ExtraConfigGithubMirrorToJson( - ExtraConfigGithubMirror instance) => - { - 'enabled': instance.enabled, - 'mirrors': instance.mirrors.map((e) => e.toJson()).toList(), - }; +Map _$ProtocolConfigToJson(ProtocolConfig instance) => { + 'http': instance.http.toJson(), + 'bt': instance.bt.toJson(), + 'ed2k': instance.ed2k.toJson(), +}; -AutoTorrentConfig _$AutoTorrentConfigFromJson(Map json) => - AutoTorrentConfig( - enable: json['enable'] as bool? ?? false, - deleteAfterDownload: json['deleteAfterDownload'] as bool? ?? false, - ); +HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( + userAgent: json['userAgent'] as String? ?? '', + connections: (json['connections'] as num?)?.toInt() ?? 0, + useServerCtime: json['useServerCtime'] as bool? ?? false, +); -Map _$AutoTorrentConfigToJson(AutoTorrentConfig instance) => - { - 'enable': instance.enable, - 'deleteAfterDownload': instance.deleteAfterDownload, - }; +Map _$HttpConfigToJson(HttpConfig instance) => { + 'userAgent': instance.userAgent, + 'connections': instance.connections, + 'useServerCtime': instance.useServerCtime, +}; -ArchiveConfig _$ArchiveConfigFromJson(Map json) => - ArchiveConfig( - autoExtract: json['autoExtract'] as bool? ?? true, - deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? true, - ); +BtConfig _$BtConfigFromJson(Map json) => BtConfig( + listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, + trackers: (json['trackers'] as List?)?.map((e) => e as String).toList() ?? const [], + seedKeep: json['seedKeep'] as bool? ?? false, + seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, + seedTime: (json['seedTime'] as num?)?.toInt() ?? 0, +); -Map _$ArchiveConfigToJson(ArchiveConfig instance) => - { - 'autoExtract': instance.autoExtract, - 'deleteAfterExtract': instance.deleteAfterExtract, - }; +Map _$BtConfigToJson(BtConfig instance) => { + 'listenPort': instance.listenPort, + 'trackers': instance.trackers, + 'seedKeep': instance.seedKeep, + 'seedRatio': instance.seedRatio, + 'seedTime': instance.seedTime, +}; + +Ed2kConfig _$Ed2kConfigFromJson(Map json) => Ed2kConfig( + listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, + udpPort: (json['udpPort'] as num?)?.toInt() ?? 0, + serverAddr: json['serverAddr'] as String? ?? '', + serverMet: json['serverMet'] as String? ?? '', + nodesDat: json['nodesDat'] as String? ?? '', +); + +Map _$Ed2kConfigToJson(Ed2kConfig instance) => { + 'listenPort': instance.listenPort, + 'udpPort': instance.udpPort, + 'serverAddr': instance.serverAddr, + 'serverMet': instance.serverMet, + 'nodesDat': instance.nodesDat, +}; + +ExtraConfig _$ExtraConfigFromJson(Map json) => + ExtraConfig( + themeMode: json['themeMode'] as String? ?? '', + themeColor: json['themeColor'] as String? ?? 'green', + locale: json['locale'] as String? ?? '', + lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false, + defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false, + defaultBtClient: json['defaultBtClient'] as bool? ?? true, + notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, + desktopNotification: json['desktopNotification'] as bool? ?? true, + backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false, + downloadCategories: + (json['downloadCategories'] as List?) + ?.map((e) => DownloadCategory.fromJson(e as Map)) + .toList() ?? + const [], + ) + ..bt = ExtraConfigBt.fromJson(json['bt'] as Map) + ..githubMirror = ExtraConfigGithubMirror.fromJson(json['githubMirror'] as Map?); + +Map _$ExtraConfigToJson(ExtraConfig instance) => { + 'themeMode': instance.themeMode, + 'themeColor': instance.themeColor, + 'locale': instance.locale, + 'lastDeleteTaskKeep': instance.lastDeleteTaskKeep, + 'defaultDirectDownload': instance.defaultDirectDownload, + 'defaultBtClient': instance.defaultBtClient, + 'notifyWhenNewVersion': instance.notifyWhenNewVersion, + 'desktopNotification': instance.desktopNotification, + 'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive, + 'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(), + 'bt': instance.bt.toJson(), + 'githubMirror': instance.githubMirror.toJson(), +}; + +DownloadCategory _$DownloadCategoryFromJson(Map json) => DownloadCategory( + name: json['name'] as String, + path: json['path'] as String, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + nameKey: json['nameKey'] as String?, + isDeleted: json['isDeleted'] as bool? ?? false, +); + +Map _$DownloadCategoryToJson(DownloadCategory instance) => { + 'name': instance.name, + 'path': instance.path, + 'isBuiltIn': instance.isBuiltIn, + 'nameKey': ?instance.nameKey, + 'isDeleted': instance.isDeleted, +}; + +WebhookConfig _$WebhookConfigFromJson(Map json) => WebhookConfig( + enable: json['enable'] as bool? ?? false, + urls: (json['urls'] as List?)?.map((e) => e as String).toList() ?? const [], +); + +Map _$WebhookConfigToJson(WebhookConfig instance) => { + 'enable': instance.enable, + 'urls': instance.urls, +}; + +ScriptConfig _$ScriptConfigFromJson(Map json) => ScriptConfig( + enable: json['enable'] as bool? ?? false, + paths: (json['paths'] as List?)?.map((e) => e as String).toList() ?? const [], +); + +Map _$ScriptConfigToJson(ScriptConfig instance) => { + 'enable': instance.enable, + 'paths': instance.paths, +}; + +ProxyConfig _$ProxyConfigFromJson(Map json) => ProxyConfig( + enable: json['enable'] as bool? ?? false, + system: json['system'] as bool? ?? false, + scheme: json['scheme'] as String? ?? '', + host: json['host'] as String? ?? '', + usr: json['usr'] as String? ?? '', + pwd: json['pwd'] as String? ?? '', +); + +Map _$ProxyConfigToJson(ProxyConfig instance) => { + 'enable': instance.enable, + 'system': instance.system, + 'scheme': instance.scheme, + 'host': instance.host, + 'usr': instance.usr, + 'pwd': instance.pwd, +}; + +ExtraConfigBt _$ExtraConfigBtFromJson(Map json) => ExtraConfigBt() + ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List).map((e) => e as String).toList() + ..subscribeTrackers = (json['subscribeTrackers'] as List).map((e) => e as String).toList() + ..autoUpdateTrackers = json['autoUpdateTrackers'] as bool + ..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null + ? null + : DateTime.parse(json['lastTrackerUpdateTime'] as String) + ..customTrackers = (json['customTrackers'] as List).map((e) => e as String).toList(); + +Map _$ExtraConfigBtToJson(ExtraConfigBt instance) => { + 'trackerSubscribeUrls': instance.trackerSubscribeUrls, + 'subscribeTrackers': instance.subscribeTrackers, + 'autoUpdateTrackers': instance.autoUpdateTrackers, + 'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(), + 'customTrackers': instance.customTrackers, +}; + +GithubMirror _$GithubMirrorFromJson(Map json) => GithubMirror( + type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']), + url: json['url'] as String, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + isDeleted: json['isDeleted'] as bool? ?? false, +); + +Map _$GithubMirrorToJson(GithubMirror instance) => { + 'type': _$GithubMirrorTypeEnumMap[instance.type]!, + 'url': instance.url, + 'isBuiltIn': instance.isBuiltIn, + 'isDeleted': instance.isDeleted, +}; + +const _$GithubMirrorTypeEnumMap = {GithubMirrorType.jsdelivr: 'jsdelivr', GithubMirrorType.ghProxy: 'ghProxy'}; + +ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(Map json) => ExtraConfigGithubMirror( + enabled: json['enabled'] as bool? ?? true, + mirrors: + (json['mirrors'] as List?)?.map((e) => GithubMirror.fromJson(e as Map)).toList() ?? + const [], +); + +Map _$ExtraConfigGithubMirrorToJson(ExtraConfigGithubMirror instance) => { + 'enabled': instance.enabled, + 'mirrors': instance.mirrors.map((e) => e.toJson()).toList(), +}; + +AutoTorrentConfig _$AutoTorrentConfigFromJson(Map json) => AutoTorrentConfig( + enable: json['enable'] as bool? ?? false, + deleteAfterDownload: json['deleteAfterDownload'] as bool? ?? false, +); + +Map _$AutoTorrentConfigToJson(AutoTorrentConfig instance) => { + 'enable': instance.enable, + 'deleteAfterDownload': instance.deleteAfterDownload, +}; + +ArchiveConfig _$ArchiveConfigFromJson(Map json) => ArchiveConfig( + autoExtract: json['autoExtract'] as bool? ?? true, + deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? true, +); + +Map _$ArchiveConfigToJson(ArchiveConfig instance) => { + 'autoExtract': instance.autoExtract, + 'deleteAfterExtract': instance.deleteAfterExtract, +}; diff --git a/ui/flutter/lib/api/model/extension.dart b/ui/flutter/lib/api/model/extension.dart index 3bd3196e..08a838dd 100644 --- a/ui/flutter/lib/api/model/extension.dart +++ b/ui/flutter/lib/api/model/extension.dart @@ -33,8 +33,7 @@ class Extension { required this.devPath, }); - factory Extension.fromJson(Map json) => - _$ExtensionFromJson(json); + factory Extension.fromJson(Map json) => _$ExtensionFromJson(json); Map toJson() => _$ExtensionToJson(this); } @@ -43,13 +42,9 @@ class Repository { String url; String directory; - Repository({ - required this.url, - required this.directory, - }); + Repository({required this.url, required this.directory}); - factory Repository.fromJson(Map json) => - _$RepositoryFromJson(json); + factory Repository.fromJson(Map json) => _$RepositoryFromJson(json); Map toJson() => _$RepositoryToJson(this); } @@ -71,8 +66,7 @@ class Setting { required this.type, }); - factory Setting.fromJson(Map json) => - _$SettingFromJson(json); + factory Setting.fromJson(Map json) => _$SettingFromJson(json); Map toJson() => _$SettingToJson(this); } @@ -81,17 +75,10 @@ class Option { String label; Object value; - Option({ - required this.label, - required this.value, - }); + Option({required this.label, required this.value}); factory Option.fromJson(Map json) => _$OptionFromJson(json); Map toJson() => _$OptionToJson(this); } -enum SettingType { - string, - number, - boolean, -} +enum SettingType { string, number, boolean } diff --git a/ui/flutter/lib/api/model/extension.g.dart b/ui/flutter/lib/api/model/extension.g.dart index 6afa4961..00bfc7f5 100644 --- a/ui/flutter/lib/api/model/extension.g.dart +++ b/ui/flutter/lib/api/model/extension.g.dart @@ -7,92 +7,64 @@ part of 'extension.dart'; // ************************************************************************** Extension _$ExtensionFromJson(Map json) => Extension( - identity: json['identity'] as String, - name: json['name'] as String, - author: json['author'] as String, - title: json['title'] as String, - description: json['description'] as String, - icon: json['icon'] as String, - version: json['version'] as String, - homepage: json['homepage'] as String, - repository: json['repository'] == null - ? null - : Repository.fromJson(json['repository'] as Map), - disabled: json['disabled'] as bool, - devMode: json['devMode'] as bool, - devPath: json['devPath'] as String, - )..settings = (json['settings'] as List?) - ?.map((e) => Setting.fromJson(e as Map)) - .toList(); + identity: json['identity'] as String, + name: json['name'] as String, + author: json['author'] as String, + title: json['title'] as String, + description: json['description'] as String, + icon: json['icon'] as String, + version: json['version'] as String, + homepage: json['homepage'] as String, + repository: json['repository'] == null ? null : Repository.fromJson(json['repository'] as Map), + disabled: json['disabled'] as bool, + devMode: json['devMode'] as bool, + devPath: json['devPath'] as String, +)..settings = (json['settings'] as List?)?.map((e) => Setting.fromJson(e as Map)).toList(); -Map _$ExtensionToJson(Extension instance) { - final val = { - 'identity': instance.identity, - 'name': instance.name, - 'author': instance.author, - 'title': instance.title, - 'description': instance.description, - 'icon': instance.icon, - 'version': instance.version, - 'homepage': instance.homepage, - }; +Map _$ExtensionToJson(Extension instance) => { + 'identity': instance.identity, + 'name': instance.name, + 'author': instance.author, + 'title': instance.title, + 'description': instance.description, + 'icon': instance.icon, + 'version': instance.version, + 'homepage': instance.homepage, + 'repository': ?instance.repository?.toJson(), + 'settings': ?instance.settings?.map((e) => e.toJson()).toList(), + 'disabled': instance.disabled, + 'devMode': instance.devMode, + 'devPath': instance.devPath, +}; - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } +Repository _$RepositoryFromJson(Map json) => + Repository(url: json['url'] as String, directory: json['directory'] as String); - writeNotNull('repository', instance.repository?.toJson()); - writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList()); - val['disabled'] = instance.disabled; - val['devMode'] = instance.devMode; - val['devPath'] = instance.devPath; - return val; -} +Map _$RepositoryToJson(Repository instance) => { + 'url': instance.url, + 'directory': instance.directory, +}; -Repository _$RepositoryFromJson(Map json) => Repository( - url: json['url'] as String, - directory: json['directory'] as String, - ); - -Map _$RepositoryToJson(Repository instance) => - { - 'url': instance.url, - 'directory': instance.directory, - }; - -Setting _$SettingFromJson(Map json) => Setting( - name: json['name'] as String, - title: json['title'] as String, - description: json['description'] as String, - required: json['required'] as bool, - type: $enumDecode(_$SettingTypeEnumMap, json['type']), - ) +Setting _$SettingFromJson(Map json) => + Setting( + name: json['name'] as String, + title: json['title'] as String, + description: json['description'] as String, + required: json['required'] as bool, + type: $enumDecode(_$SettingTypeEnumMap, json['type']), + ) ..value = json['value'] - ..options = (json['options'] as List?) - ?.map((e) => Option.fromJson(e as Map)) - .toList(); + ..options = (json['options'] as List?)?.map((e) => Option.fromJson(e as Map)).toList(); -Map _$SettingToJson(Setting instance) { - final val = { - 'name': instance.name, - 'title': instance.title, - 'description': instance.description, - 'required': instance.required, - 'type': _$SettingTypeEnumMap[instance.type]!, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('value', instance.value); - writeNotNull('options', instance.options); - return val; -} +Map _$SettingToJson(Setting instance) => { + 'name': instance.name, + 'title': instance.title, + 'description': instance.description, + 'required': instance.required, + 'type': _$SettingTypeEnumMap[instance.type]!, + 'value': ?instance.value, + 'options': ?instance.options, +}; const _$SettingTypeEnumMap = { SettingType.string: 'string', @@ -100,12 +72,10 @@ const _$SettingTypeEnumMap = { SettingType.boolean: 'boolean', }; -Option _$OptionFromJson(Map json) => Option( - label: json['label'] as String, - value: json['value'] as Object, - ); +Option _$OptionFromJson(Map json) => + Option(label: json['label'] as String, value: json['value'] as Object); Map _$OptionToJson(Option instance) => { - 'label': instance.label, - 'value': instance.value, - }; + 'label': instance.label, + 'value': instance.value, +}; diff --git a/ui/flutter/lib/api/model/install_extension.dart b/ui/flutter/lib/api/model/install_extension.dart index a5baf292..197ea8f7 100644 --- a/ui/flutter/lib/api/model/install_extension.dart +++ b/ui/flutter/lib/api/model/install_extension.dart @@ -7,12 +7,8 @@ class InstallExtension { bool devMode; String url; - InstallExtension({ - this.devMode = false, - required this.url, - }); + InstallExtension({this.devMode = false, required this.url}); - factory InstallExtension.fromJson(Map json) => - _$InstallExtensionFromJson(json); + factory InstallExtension.fromJson(Map json) => _$InstallExtensionFromJson(json); Map toJson() => _$InstallExtensionToJson(this); } diff --git a/ui/flutter/lib/api/model/install_extension.g.dart b/ui/flutter/lib/api/model/install_extension.g.dart index deabd055..61fe82ce 100644 --- a/ui/flutter/lib/api/model/install_extension.g.dart +++ b/ui/flutter/lib/api/model/install_extension.g.dart @@ -7,13 +7,9 @@ part of 'install_extension.dart'; // ************************************************************************** InstallExtension _$InstallExtensionFromJson(Map json) => - InstallExtension( - devMode: json['devMode'] as bool? ?? false, - url: json['url'] as String, - ); + InstallExtension(devMode: json['devMode'] as bool? ?? false, url: json['url'] as String); -Map _$InstallExtensionToJson(InstallExtension instance) => - { - 'devMode': instance.devMode, - 'url': instance.url, - }; +Map _$InstallExtensionToJson(InstallExtension instance) => { + 'devMode': instance.devMode, + 'url': instance.url, +}; diff --git a/ui/flutter/lib/api/model/login.dart b/ui/flutter/lib/api/model/login.dart index 41be268e..4e0cef13 100644 --- a/ui/flutter/lib/api/model/login.dart +++ b/ui/flutter/lib/api/model/login.dart @@ -7,12 +7,8 @@ class LoginReq { String username; String password; - LoginReq({ - required this.username, - required this.password, - }); + LoginReq({required this.username, required this.password}); - factory LoginReq.fromJson(Map json) => - _$LoginReqFromJson(json); + factory LoginReq.fromJson(Map json) => _$LoginReqFromJson(json); Map toJson() => _$LoginReqToJson(this); } diff --git a/ui/flutter/lib/api/model/login.g.dart b/ui/flutter/lib/api/model/login.g.dart index f347a387..349eebd6 100644 --- a/ui/flutter/lib/api/model/login.g.dart +++ b/ui/flutter/lib/api/model/login.g.dart @@ -6,12 +6,10 @@ part of 'login.dart'; // JsonSerializableGenerator // ************************************************************************** -LoginReq _$LoginReqFromJson(Map json) => LoginReq( - username: json['username'] as String, - password: json['password'] as String, - ); +LoginReq _$LoginReqFromJson(Map json) => + LoginReq(username: json['username'] as String, password: json['password'] as String); Map _$LoginReqToJson(LoginReq instance) => { - 'username': instance.username, - 'password': instance.password, - }; + 'username': instance.username, + 'password': instance.password, +}; diff --git a/ui/flutter/lib/api/model/meta.dart b/ui/flutter/lib/api/model/meta.dart index 55586f01..4515723f 100644 --- a/ui/flutter/lib/api/model/meta.dart +++ b/ui/flutter/lib/api/model/meta.dart @@ -11,10 +11,7 @@ class Meta { Resource? res; Options opts; - Meta({ - required this.req, - required this.opts, - }); + Meta({required this.req, required this.opts}); factory Meta.fromJson(Map json) => _$MetaFromJson(json); Map toJson() => _$MetaToJson(this); diff --git a/ui/flutter/lib/api/model/meta.g.dart b/ui/flutter/lib/api/model/meta.g.dart index c4cb788c..e673e86f 100644 --- a/ui/flutter/lib/api/model/meta.g.dart +++ b/ui/flutter/lib/api/model/meta.g.dart @@ -7,24 +7,12 @@ part of 'meta.dart'; // ************************************************************************** Meta _$MetaFromJson(Map json) => Meta( - req: Request.fromJson(json['req'] as Map), - opts: Options.fromJson(json['opts'] as Map), - )..res = json['res'] == null - ? null - : Resource.fromJson(json['res'] as Map); + req: Request.fromJson(json['req'] as Map), + opts: Options.fromJson(json['opts'] as Map), +)..res = json['res'] == null ? null : Resource.fromJson(json['res'] as Map); -Map _$MetaToJson(Meta instance) { - final val = { - 'req': instance.req.toJson(), - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('res', instance.res?.toJson()); - val['opts'] = instance.opts.toJson(); - return val; -} +Map _$MetaToJson(Meta instance) => { + 'req': instance.req.toJson(), + 'res': ?instance.res?.toJson(), + 'opts': instance.opts.toJson(), +}; diff --git a/ui/flutter/lib/api/model/options.dart b/ui/flutter/lib/api/model/options.dart index ca17bd35..17aadd0d 100644 --- a/ui/flutter/lib/api/model/options.dart +++ b/ui/flutter/lib/api/model/options.dart @@ -6,18 +6,13 @@ part 'options.g.dart'; class Options { String name; String path; + bool asDefaultPath; List selectFiles; Object? extra; - Options({ - this.name = '', - this.path = '', - this.selectFiles = const [], - this.extra, - }); + Options({this.name = '', this.path = '', this.asDefaultPath = false, this.selectFiles = const [], this.extra}); - factory Options.fromJson(Map json) => - _$OptionsFromJson(json); + factory Options.fromJson(Map json) => _$OptionsFromJson(json); Map toJson() => _$OptionsToJson(this); } @@ -40,8 +35,7 @@ class OptsExtraHttp { this.deleteAfterExtract = false, }); - factory OptsExtraHttp.fromJson(Map json) => - _$OptsExtraHttpFromJson(json); + factory OptsExtraHttp.fromJson(Map json) => _$OptsExtraHttpFromJson(json); Map toJson() => _$OptsExtraHttpToJson(this); } diff --git a/ui/flutter/lib/api/model/options.g.dart b/ui/flutter/lib/api/model/options.g.dart index e8e3840d..52b86453 100644 --- a/ui/flutter/lib/api/model/options.g.dart +++ b/ui/flutter/lib/api/model/options.g.dart @@ -7,58 +7,35 @@ part of 'options.dart'; // ************************************************************************** Options _$OptionsFromJson(Map json) => Options( - name: json['name'] as String? ?? '', - path: json['path'] as String? ?? '', - selectFiles: (json['selectFiles'] as List?) - ?.map((e) => (e as num).toInt()) - .toList() ?? - const [], - extra: json['extra'], - ); + name: json['name'] as String? ?? '', + path: json['path'] as String? ?? '', + asDefaultPath: json['asDefaultPath'] as bool? ?? false, + selectFiles: (json['selectFiles'] as List?)?.map((e) => (e as num).toInt()).toList() ?? const [], + extra: json['extra'], +); -Map _$OptionsToJson(Options instance) { - final val = { - 'name': instance.name, - 'path': instance.path, - 'selectFiles': instance.selectFiles, - }; +Map _$OptionsToJson(Options instance) => { + 'name': instance.name, + 'path': instance.path, + 'asDefaultPath': instance.asDefaultPath, + 'selectFiles': instance.selectFiles, + 'extra': ?instance.extra, +}; - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } +OptsExtraHttp _$OptsExtraHttpFromJson(Map json) => OptsExtraHttp( + connections: (json['connections'] as num?)?.toInt() ?? 0, + autoTorrent: json['autoTorrent'] as bool?, + deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?, + autoExtract: json['autoExtract'] as bool?, + archivePassword: json['archivePassword'] as String? ?? '', + deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false, +); - writeNotNull('extra', instance.extra); - return val; -} - -OptsExtraHttp _$OptsExtraHttpFromJson(Map json) => - OptsExtraHttp( - connections: (json['connections'] as num?)?.toInt() ?? 0, - autoTorrent: json['autoTorrent'] as bool?, - deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?, - autoExtract: json['autoExtract'] as bool?, - archivePassword: json['archivePassword'] as String? ?? '', - deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false, - ); - -Map _$OptsExtraHttpToJson(OptsExtraHttp instance) { - final val = { - 'connections': instance.connections, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('autoTorrent', instance.autoTorrent); - writeNotNull( - 'deleteTorrentAfterDownload', instance.deleteTorrentAfterDownload); - writeNotNull('autoExtract', instance.autoExtract); - val['archivePassword'] = instance.archivePassword; - val['deleteAfterExtract'] = instance.deleteAfterExtract; - return val; -} +Map _$OptsExtraHttpToJson(OptsExtraHttp instance) => { + 'connections': instance.connections, + 'autoTorrent': ?instance.autoTorrent, + 'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload, + 'autoExtract': ?instance.autoExtract, + 'archivePassword': instance.archivePassword, + 'deleteAfterExtract': instance.deleteAfterExtract, +}; diff --git a/ui/flutter/lib/api/model/piece_map_codec.dart b/ui/flutter/lib/api/model/piece_map_codec.dart new file mode 100644 index 00000000..b6824d1b --- /dev/null +++ b/ui/flutter/lib/api/model/piece_map_codec.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +enum TaskPieceState { pending, completed } + +/// Decoded view of Gopeed's ordered `bitset-v1` piece map. +/// +/// A set bit means the piece is complete and verified. The decoder keeps the +/// compact bytes instead of expanding large torrents into per-piece objects; +/// [stateAt] reads any piece in O(1). +class TaskPieceMap { + const TaskPieceMap._({ + required this.encoding, + required this.pieceCount, + required this.pieceSize, + required this.completedPieces, + required Uint8List data, + }) : _data = data; + + static const supportedEncoding = 'bitset-v1'; + + final String encoding; + final int pieceCount; + final int pieceSize; + final int completedPieces; + final Uint8List _data; + + static TaskPieceMap empty() => + TaskPieceMap._(encoding: supportedEncoding, pieceCount: 0, pieceSize: 0, completedPieces: 0, data: Uint8List(0)); + + /// Decodes the Base64 bitset returned by Go. Piece N uses bit `N % 8` of + /// byte `N ~/ 8`, from least-significant to most-significant bit. + static TaskPieceMap? fromJson(Object? value) { + if (value is! Map) return null; + final json = {for (final entry in value.entries) entry.key.toString(): entry.value}; + final encoding = json['encoding']; + final pieceCount = json['pieceCount']; + final pieceSize = json['pieceSize']; + final completedPieces = json['completedPieces']; + final encodedData = json['data']; + if (encoding != supportedEncoding || + pieceCount is! int || + pieceCount < 0 || + pieceSize is! int || + pieceSize < 0 || + completedPieces is! int || + completedPieces < 0 || + completedPieces > pieceCount || + encodedData is! String) { + return null; + } + + try { + final data = base64Decode(encodedData); + final expectedLength = (pieceCount + 7) ~/ 8; + if (data.length != expectedLength) return null; + + var actualCompleted = 0; + for (var index = 0; index < pieceCount; index++) { + if ((data[index ~/ 8] & (1 << (index % 8))) != 0) actualCompleted++; + } + if (actualCompleted != completedPieces) return null; + + return TaskPieceMap._( + encoding: encoding, + pieceCount: pieceCount, + pieceSize: pieceSize, + completedPieces: completedPieces, + data: data, + ); + } on FormatException { + return null; + } + } + + /// Packs local states with the same bit order. Used for widget construction + /// and tests; API responses are decoded through [fromJson]. + factory TaskPieceMap.fromStates(List states, {int pieceSize = 0}) { + final data = Uint8List((states.length + 7) ~/ 8); + var completedPieces = 0; + for (var index = 0; index < states.length; index++) { + if (states[index] != TaskPieceState.completed) continue; + data[index ~/ 8] |= 1 << (index % 8); + completedPieces++; + } + return TaskPieceMap._( + encoding: supportedEncoding, + pieceCount: states.length, + pieceSize: pieceSize, + completedPieces: completedPieces, + data: data, + ); + } + + bool get isEmpty => pieceCount == 0; + bool get isNotEmpty => !isEmpty; + + TaskPieceState stateAt(int index) { + RangeError.checkValidIndex(index, this, 'index', pieceCount); + final completed = (_data[index ~/ 8] & (1 << (index % 8))) != 0; + return completed ? TaskPieceState.completed : TaskPieceState.pending; + } + + bool contains(TaskPieceState state) => switch (state) { + TaskPieceState.completed => completedPieces > 0, + TaskPieceState.pending => completedPieces < pieceCount, + }; + + List toStates() => List.generate(pieceCount, stateAt, growable: false); +} diff --git a/ui/flutter/lib/api/model/request.dart b/ui/flutter/lib/api/model/request.dart index 0473f0db..11654449 100644 --- a/ui/flutter/lib/api/model/request.dart +++ b/ui/flutter/lib/api/model/request.dart @@ -11,17 +11,9 @@ class Request { RequestProxy? proxy; bool skipVerifyCert; - Request({ - this.rawUrl, - required this.url, - this.extra, - this.labels, - this.proxy, - this.skipVerifyCert = false, - }); + Request({this.rawUrl, required this.url, this.extra, this.labels, this.proxy, this.skipVerifyCert = false}); - factory Request.fromJson(Map json) => - _$RequestFromJson(json); + factory Request.fromJson(Map json) => _$RequestFromJson(json); Map toJson() => _$RequestToJson(this); } @@ -32,14 +24,9 @@ class ReqExtraHttp { Map header; String body; - ReqExtraHttp({ - this.method = 'GET', - this.header = const {}, - this.body = '', - }); + ReqExtraHttp({this.method = 'GET', this.header = const {}, this.body = ''}); - factory ReqExtraHttp.fromJson(Map json) => - _$ReqExtraHttpFromJson(json); + factory ReqExtraHttp.fromJson(Map json) => _$ReqExtraHttpFromJson(json); Map toJson() => _$ReqExtraHttpToJson(this); } @@ -48,21 +35,14 @@ class ReqExtraHttp { class ReqExtraBt { List trackers; - ReqExtraBt({ - this.trackers = const [], - }); + ReqExtraBt({this.trackers = const []}); - factory ReqExtraBt.fromJson(Map json) => - _$ReqExtraBtFromJson(json); + factory ReqExtraBt.fromJson(Map json) => _$ReqExtraBtFromJson(json); Map toJson() => _$ReqExtraBtToJson(this); } -enum RequestProxyMode { - follow, - none, - custom, -} +enum RequestProxyMode { follow, none, custom } @JsonSerializable() class RequestProxy { @@ -80,8 +60,7 @@ class RequestProxy { this.pwd = '', }); - factory RequestProxy.fromJson(Map json) => - _$RequestProxyFromJson(json); + factory RequestProxy.fromJson(Map json) => _$RequestProxyFromJson(json); Map toJson() => _$RequestProxyToJson(this); } diff --git a/ui/flutter/lib/api/model/request.g.dart b/ui/flutter/lib/api/model/request.g.dart index f4e849f5..e4e3c476 100644 --- a/ui/flutter/lib/api/model/request.g.dart +++ b/ui/flutter/lib/api/model/request.g.dart @@ -7,82 +7,55 @@ part of 'request.dart'; // ************************************************************************** Request _$RequestFromJson(Map json) => Request( - rawUrl: json['rawUrl'] as String?, - url: json['url'] as String, - extra: json['extra'], - labels: (json['labels'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - proxy: json['proxy'] == null - ? null - : RequestProxy.fromJson(json['proxy'] as Map), - skipVerifyCert: json['skipVerifyCert'] as bool? ?? false, - ); + rawUrl: json['rawUrl'] as String?, + url: json['url'] as String, + extra: json['extra'], + labels: (json['labels'] as Map?)?.map((k, e) => MapEntry(k, e as String)), + proxy: json['proxy'] == null ? null : RequestProxy.fromJson(json['proxy'] as Map), + skipVerifyCert: json['skipVerifyCert'] as bool? ?? false, +); -Map _$RequestToJson(Request instance) { - final val = { - 'rawUrl': instance.rawUrl, - 'url': instance.url, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('extra', instance.extra); - writeNotNull('labels', instance.labels); - writeNotNull('proxy', instance.proxy?.toJson()); - val['skipVerifyCert'] = instance.skipVerifyCert; - return val; -} +Map _$RequestToJson(Request instance) => { + 'rawUrl': ?instance.rawUrl, + 'url': instance.url, + 'extra': ?instance.extra, + 'labels': ?instance.labels, + 'proxy': ?instance.proxy?.toJson(), + 'skipVerifyCert': instance.skipVerifyCert, +}; ReqExtraHttp _$ReqExtraHttpFromJson(Map json) => ReqExtraHttp( - method: json['method'] as String? ?? 'GET', - header: (json['header'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ) ?? - const {}, - body: json['body'] as String? ?? '', - ); + method: json['method'] as String? ?? 'GET', + header: (json['header'] as Map?)?.map((k, e) => MapEntry(k, e as String)) ?? const {}, + body: json['body'] as String? ?? '', +); -Map _$ReqExtraHttpToJson(ReqExtraHttp instance) => - { - 'method': instance.method, - 'header': instance.header, - 'body': instance.body, - }; +Map _$ReqExtraHttpToJson(ReqExtraHttp instance) => { + 'method': instance.method, + 'header': instance.header, + 'body': instance.body, +}; -ReqExtraBt _$ReqExtraBtFromJson(Map json) => ReqExtraBt( - trackers: (json['trackers'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], - ); +ReqExtraBt _$ReqExtraBtFromJson(Map json) => + ReqExtraBt(trackers: (json['trackers'] as List?)?.map((e) => e as String).toList() ?? const []); -Map _$ReqExtraBtToJson(ReqExtraBt instance) => - { - 'trackers': instance.trackers, - }; +Map _$ReqExtraBtToJson(ReqExtraBt instance) => {'trackers': instance.trackers}; RequestProxy _$RequestProxyFromJson(Map json) => RequestProxy( - mode: $enumDecodeNullable(_$RequestProxyModeEnumMap, json['mode']) ?? - RequestProxyMode.follow, - scheme: json['scheme'] as String? ?? 'http', - host: json['host'] as String? ?? '', - usr: json['usr'] as String? ?? '', - pwd: json['pwd'] as String? ?? '', - ); + mode: $enumDecodeNullable(_$RequestProxyModeEnumMap, json['mode']) ?? RequestProxyMode.follow, + scheme: json['scheme'] as String? ?? 'http', + host: json['host'] as String? ?? '', + usr: json['usr'] as String? ?? '', + pwd: json['pwd'] as String? ?? '', +); -Map _$RequestProxyToJson(RequestProxy instance) => - { - 'mode': _$RequestProxyModeEnumMap[instance.mode]!, - 'scheme': instance.scheme, - 'host': instance.host, - 'usr': instance.usr, - 'pwd': instance.pwd, - }; +Map _$RequestProxyToJson(RequestProxy instance) => { + 'mode': _$RequestProxyModeEnumMap[instance.mode]!, + 'scheme': instance.scheme, + 'host': instance.host, + 'usr': instance.usr, + 'pwd': instance.pwd, +}; const _$RequestProxyModeEnumMap = { RequestProxyMode.follow: 'follow', diff --git a/ui/flutter/lib/api/model/resolve_result.dart b/ui/flutter/lib/api/model/resolve_result.dart index 642cfecf..6897314c 100644 --- a/ui/flutter/lib/api/model/resolve_result.dart +++ b/ui/flutter/lib/api/model/resolve_result.dart @@ -9,12 +9,8 @@ class ResolveResult { String id; Resource res; - ResolveResult({ - this.id = "", - required this.res, - }); + ResolveResult({this.id = "", required this.res}); - factory ResolveResult.fromJson(Map json) => - _$ResolveResultFromJson(json); + factory ResolveResult.fromJson(Map json) => _$ResolveResultFromJson(json); Map toJson() => _$ResolveResultToJson(this); } diff --git a/ui/flutter/lib/api/model/resolve_result.g.dart b/ui/flutter/lib/api/model/resolve_result.g.dart index c62d958d..afee28bf 100644 --- a/ui/flutter/lib/api/model/resolve_result.g.dart +++ b/ui/flutter/lib/api/model/resolve_result.g.dart @@ -7,13 +7,9 @@ part of 'resolve_result.dart'; // ************************************************************************** ResolveResult _$ResolveResultFromJson(Map json) => - ResolveResult( - id: json['id'] as String? ?? "", - res: Resource.fromJson(json['res'] as Map), - ); + ResolveResult(id: json['id'] as String? ?? "", res: Resource.fromJson(json['res'] as Map)); -Map _$ResolveResultToJson(ResolveResult instance) => - { - 'id': instance.id, - 'res': instance.res.toJson(), - }; +Map _$ResolveResultToJson(ResolveResult instance) => { + 'id': instance.id, + 'res': instance.res.toJson(), +}; diff --git a/ui/flutter/lib/api/model/resolve_task.dart b/ui/flutter/lib/api/model/resolve_task.dart index 6ec5eba7..c1458e75 100644 --- a/ui/flutter/lib/api/model/resolve_task.dart +++ b/ui/flutter/lib/api/model/resolve_task.dart @@ -10,12 +10,8 @@ class ResolveTask { Request? req; Options? opts; - ResolveTask({ - this.req, - this.opts, - }); + ResolveTask({this.req, this.opts}); - factory ResolveTask.fromJson(Map json) => - _$ResolveTaskFromJson(json); + factory ResolveTask.fromJson(Map json) => _$ResolveTaskFromJson(json); Map toJson() => _$ResolveTaskToJson(this); } diff --git a/ui/flutter/lib/api/model/resolve_task.g.dart b/ui/flutter/lib/api/model/resolve_task.g.dart index df0ca4cd..90680bda 100644 --- a/ui/flutter/lib/api/model/resolve_task.g.dart +++ b/ui/flutter/lib/api/model/resolve_task.g.dart @@ -7,24 +7,11 @@ part of 'resolve_task.dart'; // ************************************************************************** ResolveTask _$ResolveTaskFromJson(Map json) => ResolveTask( - req: json['req'] == null - ? null - : Request.fromJson(json['req'] as Map), - opts: json['opts'] == null - ? null - : Options.fromJson(json['opts'] as Map), - ); + req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), + opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), +); -Map _$ResolveTaskToJson(ResolveTask instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('req', instance.req); - writeNotNull('opts', instance.opts); - return val; -} +Map _$ResolveTaskToJson(ResolveTask instance) => { + 'req': ?instance.req, + 'opts': ?instance.opts, +}; diff --git a/ui/flutter/lib/api/model/resource.dart b/ui/flutter/lib/api/model/resource.dart index 61b3af76..94771bdc 100644 --- a/ui/flutter/lib/api/model/resource.dart +++ b/ui/flutter/lib/api/model/resource.dart @@ -11,15 +11,9 @@ class Resource { List files; String hash; - Resource( - {this.name = "", - this.size = 0, - this.range = false, - required this.files, - this.hash = ""}); + Resource({this.name = "", this.size = 0, this.range = false, required this.files, this.hash = ""}); - factory Resource.fromJson(Map json) => - _$ResourceFromJson(json); + factory Resource.fromJson(Map json) => _$ResourceFromJson(json); Map toJson() => _$ResourceToJson(this); } @@ -31,15 +25,9 @@ class FileInfo { int size; Request? req; - FileInfo({ - this.path = "", - required this.name, - this.size = 0, - this.req, - }); + FileInfo({this.path = "", required this.name, this.size = 0, this.req}); - factory FileInfo.fromJson(Map json) => - _$FileInfoFromJson(json); + factory FileInfo.fromJson(Map json) => _$FileInfoFromJson(json); Map toJson() => _$FileInfoToJson(this); } diff --git a/ui/flutter/lib/api/model/resource.g.dart b/ui/flutter/lib/api/model/resource.g.dart index 0a5e8fc8..56d36e31 100644 --- a/ui/flutter/lib/api/model/resource.g.dart +++ b/ui/flutter/lib/api/model/resource.g.dart @@ -7,45 +7,31 @@ part of 'resource.dart'; // ************************************************************************** Resource _$ResourceFromJson(Map json) => Resource( - name: json['name'] as String? ?? "", - size: (json['size'] as num?)?.toInt() ?? 0, - range: json['range'] as bool? ?? false, - files: (json['files'] as List) - .map((e) => FileInfo.fromJson(e as Map)) - .toList(), - hash: json['hash'] as String? ?? "", - ); + name: json['name'] as String? ?? "", + size: (json['size'] as num?)?.toInt() ?? 0, + range: json['range'] as bool? ?? false, + files: (json['files'] as List).map((e) => FileInfo.fromJson(e as Map)).toList(), + hash: json['hash'] as String? ?? "", +); Map _$ResourceToJson(Resource instance) => { - 'name': instance.name, - 'size': instance.size, - 'range': instance.range, - 'files': instance.files.map((e) => e.toJson()).toList(), - 'hash': instance.hash, - }; + 'name': instance.name, + 'size': instance.size, + 'range': instance.range, + 'files': instance.files.map((e) => e.toJson()).toList(), + 'hash': instance.hash, +}; FileInfo _$FileInfoFromJson(Map json) => FileInfo( - path: json['path'] as String? ?? "", - name: json['name'] as String, - size: (json['size'] as num?)?.toInt() ?? 0, - req: json['req'] == null - ? null - : Request.fromJson(json['req'] as Map), - ); + path: json['path'] as String? ?? "", + name: json['name'] as String, + size: (json['size'] as num?)?.toInt() ?? 0, + req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), +); -Map _$FileInfoToJson(FileInfo instance) { - final val = { - 'path': instance.path, - 'name': instance.name, - 'size': instance.size, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('req', instance.req?.toJson()); - return val; -} +Map _$FileInfoToJson(FileInfo instance) => { + 'path': instance.path, + 'name': instance.name, + 'size': instance.size, + 'req': ?instance.req?.toJson(), +}; diff --git a/ui/flutter/lib/api/model/result.dart b/ui/flutter/lib/api/model/result.dart index 7c312ed8..c73b8781 100644 --- a/ui/flutter/lib/api/model/result.dart +++ b/ui/flutter/lib/api/model/result.dart @@ -2,28 +2,19 @@ import 'package:json_annotation/json_annotation.dart'; part 'result.g.dart'; -@JsonSerializable(genericArgumentFactories: true) +@JsonSerializable(genericArgumentFactories: true, createToJson: false) class Result { int code; String? msg; T? data; - Result({ - required this.code, - this.msg, - this.data, - }); + Result({required this.code, this.msg, this.data}); - factory Result.fromJson( - Map json, - T Function(dynamic json) fromJsonT, - ) => + factory Result.fromJson(Map json, T Function(dynamic json) fromJsonT) => _$ResultFromJson(json, fromJsonT); Map toJson() => { - 'code': code, - 'msg': msg, - 'data': data is List - ? (data as dynamic)?.map((e) => e.toJson()).toList() - : (data as dynamic)?.toJson(), - }; + 'code': code, + 'msg': msg, + 'data': data is List ? (data as dynamic)?.map((e) => e.toJson()).toList() : (data as dynamic)?.toJson(), + }; } diff --git a/ui/flutter/lib/api/model/result.g.dart b/ui/flutter/lib/api/model/result.g.dart index 03d4f734..bc28bb15 100644 --- a/ui/flutter/lib/api/model/result.g.dart +++ b/ui/flutter/lib/api/model/result.g.dart @@ -6,43 +6,11 @@ part of 'result.dart'; // JsonSerializableGenerator // ************************************************************************** -Result _$ResultFromJson( - Map json, - T Function(Object? json) fromJsonT, -) => - Result( - code: (json['code'] as num).toInt(), - msg: json['msg'] as String?, - data: _$nullableGenericFromJson(json['data'], fromJsonT), - ); +Result _$ResultFromJson(Map json, T Function(Object? json) fromJsonT) => Result( + code: (json['code'] as num).toInt(), + msg: json['msg'] as String?, + data: _$nullableGenericFromJson(json['data'], fromJsonT), +); -Map _$ResultToJson( - Result instance, - Object? Function(T value) toJsonT, -) { - final val = { - 'code': instance.code, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('msg', instance.msg); - writeNotNull('data', _$nullableGenericToJson(instance.data, toJsonT)); - return val; -} - -T? _$nullableGenericFromJson( - Object? input, - T Function(Object? json) fromJson, -) => +T? _$nullableGenericFromJson(Object? input, T Function(Object? json) fromJson) => input == null ? null : fromJson(input); - -Object? _$nullableGenericToJson( - T? input, - Object? Function(T value) toJson, -) => - input == null ? null : toJson(input); diff --git a/ui/flutter/lib/api/model/store_extension.dart b/ui/flutter/lib/api/model/store_extension.dart index 50803887..ca5a9b7b 100644 --- a/ui/flutter/lib/api/model/store_extension.dart +++ b/ui/flutter/lib/api/model/store_extension.dart @@ -11,8 +11,7 @@ class StoreExtensionPage { data: (json['data'] as List? ?? []) .map((e) => StoreExtension.fromJson(e as Map)) .toList(), - pagination: StorePagination.fromJson( - json['pagination'] as Map? ?? {}), + pagination: StorePagination.fromJson(json['pagination'] as Map? ?? {}), ); } } @@ -137,13 +136,6 @@ class StoreExtension { } } -enum StoreExtensionSort { - stars, - installs, - updated, -} +enum StoreExtensionSort { stars, installs, updated } -enum StoreSortOrder { - asc, - desc, -} +enum StoreSortOrder { asc, desc } diff --git a/ui/flutter/lib/api/model/switch_extension.dart b/ui/flutter/lib/api/model/switch_extension.dart index f00c6c58..9e994143 100644 --- a/ui/flutter/lib/api/model/switch_extension.dart +++ b/ui/flutter/lib/api/model/switch_extension.dart @@ -6,11 +6,8 @@ part 'switch_extension.g.dart'; class SwitchExtension { bool status; - SwitchExtension({ - required this.status, - }); + SwitchExtension({required this.status}); - factory SwitchExtension.fromJson(Map json) => - _$SwitchExtensionFromJson(json); + factory SwitchExtension.fromJson(Map json) => _$SwitchExtensionFromJson(json); Map toJson() => _$SwitchExtensionToJson(this); } diff --git a/ui/flutter/lib/api/model/switch_extension.g.dart b/ui/flutter/lib/api/model/switch_extension.g.dart index c3b50559..8f1cfa0c 100644 --- a/ui/flutter/lib/api/model/switch_extension.g.dart +++ b/ui/flutter/lib/api/model/switch_extension.g.dart @@ -6,12 +6,6 @@ part of 'switch_extension.dart'; // JsonSerializableGenerator // ************************************************************************** -SwitchExtension _$SwitchExtensionFromJson(Map json) => - SwitchExtension( - status: json['status'] as bool, - ); +SwitchExtension _$SwitchExtensionFromJson(Map json) => SwitchExtension(status: json['status'] as bool); -Map _$SwitchExtensionToJson(SwitchExtension instance) => - { - 'status': instance.status, - }; +Map _$SwitchExtensionToJson(SwitchExtension instance) => {'status': instance.status}; diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index 830d772d..cca65ed9 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -12,6 +12,8 @@ enum Protocol { http, bt, ed2k } enum ExtractStatus { @JsonValue('') none, + @JsonValue('queued') + queued, @JsonValue('extracting') extracting, @JsonValue('done') @@ -19,7 +21,7 @@ enum ExtractStatus { @JsonValue('error') error, @JsonValue('waitingParts') - waitingParts + waitingParts, } @JsonSerializable(explicitToJson: true) @@ -70,8 +72,51 @@ class Progress { this.extractProgress = 0, }); - factory Progress.fromJson(Map json) => - _$ProgressFromJson(json); + factory Progress.fromJson(Map json) => _$ProgressFromJson(json); Map toJson() => _$ProgressToJson(this); } + +@JsonSerializable() +class FileRuntimeStatus { + int index; + int size; + int downloaded; + + FileRuntimeStatus({required this.index, required this.size, required this.downloaded}); + + factory FileRuntimeStatus.fromJson(Map json) => _$FileRuntimeStatusFromJson(json); + + Map toJson() => _$FileRuntimeStatusToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TaskRuntimeStatus { + Status status; + int used; + int speed; + int downloaded; + int total; + int uploadSpeed; + int uploaded; + ExtractStatus extractStatus; + int extractProgress; + List files; + + TaskRuntimeStatus({ + required this.status, + required this.used, + required this.speed, + required this.downloaded, + required this.total, + required this.uploadSpeed, + required this.uploaded, + this.extractStatus = ExtractStatus.none, + this.extractProgress = 0, + required this.files, + }); + + factory TaskRuntimeStatus.fromJson(Map json) => _$TaskRuntimeStatusFromJson(json); + + Map toJson() => _$TaskRuntimeStatusToJson(this); +} diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index b2889e73..15b3b277 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -7,37 +7,27 @@ part of 'task.dart'; // ************************************************************************** Task _$TaskFromJson(Map json) => Task( - id: json['id'] as String, - name: json['name'] as String, - meta: Meta.fromJson(json['meta'] as Map), - status: $enumDecode(_$StatusEnumMap, json['status']), - uploading: json['uploading'] as bool, - progress: Progress.fromJson(json['progress'] as Map), - createdAt: DateTime.parse(json['createdAt'] as String), - updatedAt: DateTime.parse(json['updatedAt'] as String), - )..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']); + id: json['id'] as String, + name: json['name'] as String, + meta: Meta.fromJson(json['meta'] as Map), + status: $enumDecode(_$StatusEnumMap, json['status']), + uploading: json['uploading'] as bool, + progress: Progress.fromJson(json['progress'] as Map), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), +)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']); -Map _$TaskToJson(Task instance) { - final val = { - 'id': instance.id, - 'name': instance.name, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('protocol', _$ProtocolEnumMap[instance.protocol]); - val['meta'] = instance.meta.toJson(); - val['status'] = _$StatusEnumMap[instance.status]!; - val['uploading'] = instance.uploading; - val['progress'] = instance.progress.toJson(); - val['createdAt'] = instance.createdAt.toIso8601String(); - val['updatedAt'] = instance.updatedAt.toIso8601String(); - return val; -} +Map _$TaskToJson(Task instance) => { + 'id': instance.id, + 'name': instance.name, + 'protocol': ?_$ProtocolEnumMap[instance.protocol], + 'meta': instance.meta.toJson(), + 'status': _$StatusEnumMap[instance.status]!, + 'uploading': instance.uploading, + 'progress': instance.progress.toJson(), + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt.toIso8601String(), +}; const _$StatusEnumMap = { Status.ready: 'ready', @@ -48,38 +38,71 @@ const _$StatusEnumMap = { Status.done: 'done', }; -const _$ProtocolEnumMap = { - Protocol.http: 'http', - Protocol.bt: 'bt', - Protocol.ed2k: 'ed2k', -}; +const _$ProtocolEnumMap = {Protocol.http: 'http', Protocol.bt: 'bt', Protocol.ed2k: 'ed2k'}; Progress _$ProgressFromJson(Map json) => Progress( - used: (json['used'] as num).toInt(), - speed: (json['speed'] as num).toInt(), - downloaded: (json['downloaded'] as num).toInt(), - uploadSpeed: (json['uploadSpeed'] as num).toInt(), - uploaded: (json['uploaded'] as num).toInt(), - extractStatus: - $enumDecodeNullable(_$ExtractStatusEnumMap, json['extractStatus']) ?? - ExtractStatus.none, - extractProgress: (json['extractProgress'] as num?)?.toInt() ?? 0, - ); + used: (json['used'] as num).toInt(), + speed: (json['speed'] as num).toInt(), + downloaded: (json['downloaded'] as num).toInt(), + uploadSpeed: (json['uploadSpeed'] as num).toInt(), + uploaded: (json['uploaded'] as num).toInt(), + extractStatus: $enumDecodeNullable(_$ExtractStatusEnumMap, json['extractStatus']) ?? ExtractStatus.none, + extractProgress: (json['extractProgress'] as num?)?.toInt() ?? 0, +); Map _$ProgressToJson(Progress instance) => { - 'used': instance.used, - 'speed': instance.speed, - 'downloaded': instance.downloaded, - 'uploadSpeed': instance.uploadSpeed, - 'uploaded': instance.uploaded, - 'extractStatus': _$ExtractStatusEnumMap[instance.extractStatus]!, - 'extractProgress': instance.extractProgress, - }; + 'used': instance.used, + 'speed': instance.speed, + 'downloaded': instance.downloaded, + 'uploadSpeed': instance.uploadSpeed, + 'uploaded': instance.uploaded, + 'extractStatus': _$ExtractStatusEnumMap[instance.extractStatus]!, + 'extractProgress': instance.extractProgress, +}; const _$ExtractStatusEnumMap = { ExtractStatus.none: '', + ExtractStatus.queued: 'queued', ExtractStatus.extracting: 'extracting', ExtractStatus.done: 'done', ExtractStatus.error: 'error', ExtractStatus.waitingParts: 'waitingParts', }; + +FileRuntimeStatus _$FileRuntimeStatusFromJson(Map json) => FileRuntimeStatus( + index: (json['index'] as num).toInt(), + size: (json['size'] as num).toInt(), + downloaded: (json['downloaded'] as num).toInt(), +); + +Map _$FileRuntimeStatusToJson(FileRuntimeStatus instance) => { + 'index': instance.index, + 'size': instance.size, + 'downloaded': instance.downloaded, +}; + +TaskRuntimeStatus _$TaskRuntimeStatusFromJson(Map json) => TaskRuntimeStatus( + status: $enumDecode(_$StatusEnumMap, json['status']), + used: (json['used'] as num).toInt(), + speed: (json['speed'] as num).toInt(), + downloaded: (json['downloaded'] as num).toInt(), + total: (json['total'] as num).toInt(), + uploadSpeed: (json['uploadSpeed'] as num).toInt(), + uploaded: (json['uploaded'] as num).toInt(), + extractStatus: $enumDecodeNullable(_$ExtractStatusEnumMap, json['extractStatus']) ?? ExtractStatus.none, + extractProgress: (json['extractProgress'] as num?)?.toInt() ?? 0, + files: (json['files'] as List).map((e) => FileRuntimeStatus.fromJson(e as Map)).toList(), +); + +Map _$TaskRuntimeStatusToJson(TaskRuntimeStatus instance) => { + 'status': _$StatusEnumMap[instance.status]!, + 'used': instance.used, + 'speed': instance.speed, + 'downloaded': instance.downloaded, + 'total': instance.total, + 'uploadSpeed': instance.uploadSpeed, + 'uploaded': instance.uploaded, + 'extractStatus': _$ExtractStatusEnumMap[instance.extractStatus]!, + 'extractProgress': instance.extractProgress, + 'files': instance.files.map((e) => e.toJson()).toList(), +}; diff --git a/ui/flutter/lib/api/model/task_stats.dart b/ui/flutter/lib/api/model/task_stats.dart new file mode 100644 index 00000000..4dafd0b0 --- /dev/null +++ b/ui/flutter/lib/api/model/task_stats.dart @@ -0,0 +1,207 @@ +export 'piece_map_codec.dart' show TaskPieceMap, TaskPieceState; + +import 'piece_map_codec.dart'; +import 'task.dart'; + +class TaskPeerStats { + const TaskPeerStats({ + required this.address, + required this.client, + required this.downloadSpeed, + required this.uploadSpeed, + required this.pieceCount, + required this.completion, + required this.relevance, + required this.source, + required this.transport, + }); + + final String address; + final String client; + final int downloadSpeed; + final int uploadSpeed; + final int pieceCount; + final double? completion; + final double? relevance; + final String source; + final String transport; + + factory TaskPeerStats.fromJson(Map json) { + return TaskPeerStats( + address: _string(json, 'address'), + client: _string(json, 'client'), + downloadSpeed: _integer(json, 'downloadSpeed'), + uploadSpeed: _integer(json, 'uploadSpeed'), + pieceCount: _integer(json, 'pieceCount'), + completion: _optionalDecimal(json, 'completion'), + relevance: _optionalDecimal(json, 'relevance'), + source: _string(json, 'source'), + transport: _string(json, 'transport'), + ); + } +} + +sealed class TaskStats { + const TaskStats({required this.peers, required this.pieceMap}); + + final List peers; + final TaskPieceMap pieceMap; + + static TaskStats? fromJson(Protocol? protocol, Map json) { + final snapshot = _map(json['snapshot']); + final runtime = _map(json['runtime']); + if (snapshot.isEmpty && runtime.isEmpty) return null; + return switch (protocol) { + Protocol.http => HttpTaskStats.fromJson(snapshot), + Protocol.bt => BtTaskStats.fromJson(snapshot: snapshot, runtime: runtime), + Protocol.ed2k => Ed2kTaskStats.fromJson(snapshot: snapshot, runtime: runtime), + null => null, + }; + } +} + +class HttpConnectionStats { + const HttpConnectionStats({ + required this.downloaded, + required this.total, + required this.completed, + required this.failed, + required this.retryTimes, + }); + + final int downloaded; + final int total; + final bool completed; + final bool failed; + final int retryTimes; + + factory HttpConnectionStats.fromJson(Map json) { + return HttpConnectionStats( + downloaded: _integer(json, 'downloaded'), + total: _integer(json, 'total'), + completed: json['completed'] == true, + failed: json['failed'] == true, + retryTimes: _integer(json, 'retryTimes'), + ); + } +} + +class HttpTaskStats extends TaskStats { + HttpTaskStats({required this.connections}) : super(peers: const [], pieceMap: TaskPieceMap.empty()); + + final List connections; + + factory HttpTaskStats.fromJson(Map json) { + return HttpTaskStats( + connections: _maps(json['connections']).map(HttpConnectionStats.fromJson).toList(growable: false), + ); + } +} + +class BtTaskStats extends TaskStats { + const BtTaskStats({ + required this.totalPeers, + required this.activePeers, + required this.connectedSeeders, + required this.connectedLeechers, + required this.seedBytes, + required this.seedRatio, + required this.seedTime, + required super.peers, + required super.pieceMap, + }); + + final int totalPeers; + final int activePeers; + final int connectedSeeders; + final int connectedLeechers; + final int seedBytes; + final double seedRatio; + final int seedTime; + + factory BtTaskStats.fromJson({required Map snapshot, required Map runtime}) { + return BtTaskStats( + totalPeers: _integer(runtime, 'totalPeers'), + activePeers: _integer(runtime, 'activePeers'), + connectedSeeders: _integer(runtime, 'connectedSeeders'), + connectedLeechers: _integer(runtime, 'connectedLeechers'), + seedBytes: _integer(snapshot, 'seedBytes'), + seedRatio: _decimal(snapshot, 'seedRatio'), + seedTime: _integer(snapshot, 'seedTime'), + peers: _peerList(runtime['peers']), + pieceMap: TaskPieceMap.fromJson(snapshot['pieceMap']) ?? TaskPieceMap.empty(), + ); + } +} + +class Ed2kTaskStats extends TaskStats { + const Ed2kTaskStats({ + required this.activePeers, + required this.totalPeers, + required this.downloadRate, + required this.upload, + required this.uploadRate, + required this.totalDone, + required this.totalWanted, + required super.peers, + required super.pieceMap, + }); + + final int activePeers; + final int totalPeers; + final int downloadRate; + final int upload; + final int uploadRate; + final int totalDone; + final int totalWanted; + int get completedPieces => pieceMap.completedPieces; + + factory Ed2kTaskStats.fromJson({required Map snapshot, required Map runtime}) { + return Ed2kTaskStats( + activePeers: _integer(runtime, 'activePeers'), + totalPeers: _integer(runtime, 'totalPeers'), + downloadRate: _integer(runtime, 'downloadRate'), + upload: _integer(snapshot, 'upload'), + uploadRate: _integer(runtime, 'uploadRate'), + totalDone: _integer(snapshot, 'totalDone'), + totalWanted: _integer(snapshot, 'totalWanted'), + peers: _peerList(runtime['peers']), + pieceMap: TaskPieceMap.fromJson(snapshot['pieceMap']) ?? TaskPieceMap.empty(), + ); + } +} + +List> _maps(Object? value) { + if (value is! List) return const []; + return value + .whereType() + .map((item) => {for (final entry in item.entries) entry.key.toString(): entry.value}) + .toList(growable: false); +} + +Map _map(Object? value) { + if (value is! Map) return const {}; + return {for (final entry in value.entries) entry.key.toString(): entry.value}; +} + +List _peerList(Object? value) => _maps(value).map(TaskPeerStats.fromJson).toList(growable: false); + +int _integer(Map json, String key) => switch (json[key]) { + final int value => value, + _ => 0, +}; + +double _decimal(Map json, String key) => switch (json[key]) { + final num value => value.toDouble(), + _ => 0, +}; + +double? _optionalDecimal(Map json, String key) => switch (json[key]) { + final num value => value.toDouble(), + _ => null, +}; + +String _string(Map json, String key) => switch (json[key]) { + final String value when value.isNotEmpty => value, + _ => '—', +}; diff --git a/ui/flutter/lib/api/model/update_check_extension_resp.dart b/ui/flutter/lib/api/model/update_check_extension_resp.dart index 02ec4fbf..13cb365b 100644 --- a/ui/flutter/lib/api/model/update_check_extension_resp.dart +++ b/ui/flutter/lib/api/model/update_check_extension_resp.dart @@ -6,11 +6,8 @@ part 'update_check_extension_resp.g.dart'; class UpdateCheckExtensionResp { String newVersion; - UpdateCheckExtensionResp({ - required this.newVersion, - }); + UpdateCheckExtensionResp({required this.newVersion}); - factory UpdateCheckExtensionResp.fromJson(Map json) => - _$UpdateCheckExtensionRespFromJson(json); + factory UpdateCheckExtensionResp.fromJson(Map json) => _$UpdateCheckExtensionRespFromJson(json); Map toJson() => _$UpdateCheckExtensionRespToJson(this); } diff --git a/ui/flutter/lib/api/model/update_check_extension_resp.g.dart b/ui/flutter/lib/api/model/update_check_extension_resp.g.dart index d2beb7e3..ae069863 100644 --- a/ui/flutter/lib/api/model/update_check_extension_resp.g.dart +++ b/ui/flutter/lib/api/model/update_check_extension_resp.g.dart @@ -6,14 +6,9 @@ part of 'update_check_extension_resp.dart'; // JsonSerializableGenerator // ************************************************************************** -UpdateCheckExtensionResp _$UpdateCheckExtensionRespFromJson( - Map json) => - UpdateCheckExtensionResp( - newVersion: json['newVersion'] as String, - ); +UpdateCheckExtensionResp _$UpdateCheckExtensionRespFromJson(Map json) => + UpdateCheckExtensionResp(newVersion: json['newVersion'] as String); -Map _$UpdateCheckExtensionRespToJson( - UpdateCheckExtensionResp instance) => - { - 'newVersion': instance.newVersion, - }; +Map _$UpdateCheckExtensionRespToJson(UpdateCheckExtensionResp instance) => { + 'newVersion': instance.newVersion, +}; diff --git a/ui/flutter/lib/api/model/update_extension_settings.dart b/ui/flutter/lib/api/model/update_extension_settings.dart index debe5202..e004676a 100644 --- a/ui/flutter/lib/api/model/update_extension_settings.dart +++ b/ui/flutter/lib/api/model/update_extension_settings.dart @@ -6,11 +6,8 @@ part 'update_extension_settings.g.dart'; class UpdateExtensionSettings { Map settings; - UpdateExtensionSettings({ - required this.settings, - }); + UpdateExtensionSettings({required this.settings}); - factory UpdateExtensionSettings.fromJson(Map json) => - _$UpdateExtensionSettingsFromJson(json); + factory UpdateExtensionSettings.fromJson(Map json) => _$UpdateExtensionSettingsFromJson(json); Map toJson() => _$UpdateExtensionSettingsToJson(this); } diff --git a/ui/flutter/lib/api/model/update_extension_settings.g.dart b/ui/flutter/lib/api/model/update_extension_settings.g.dart index 70eb3cae..d746da4c 100644 --- a/ui/flutter/lib/api/model/update_extension_settings.g.dart +++ b/ui/flutter/lib/api/model/update_extension_settings.g.dart @@ -6,14 +6,9 @@ part of 'update_extension_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -UpdateExtensionSettings _$UpdateExtensionSettingsFromJson( - Map json) => - UpdateExtensionSettings( - settings: json['settings'] as Map, - ); +UpdateExtensionSettings _$UpdateExtensionSettingsFromJson(Map json) => + UpdateExtensionSettings(settings: json['settings'] as Map); -Map _$UpdateExtensionSettingsToJson( - UpdateExtensionSettings instance) => - { - 'settings': instance.settings, - }; +Map _$UpdateExtensionSettingsToJson(UpdateExtensionSettings instance) => { + 'settings': instance.settings, +}; diff --git a/ui/flutter/lib/app/app.dart b/ui/flutter/lib/app/app.dart new file mode 100644 index 00000000..d93a404e --- /dev/null +++ b/ui/flutter/lib/app/app.dart @@ -0,0 +1,104 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../core/window/app_window_frame.dart'; +import '../core/window/app_window_appearance.dart'; +import '../core/window/window_capability_transport.dart'; +import '../l10n/l10n.dart'; +import '../shared/theme/app_component_themes.dart'; +import '../shared/theme/app_theme.dart'; +import '../features/auth/application/web_auth_controller.dart'; +import 'application/app_appearance_controller.dart'; +import 'application/app_runtime_controller.dart'; +import 'rpc/webview_rpc_overlay.dart'; +import 'rpc/webview_rpc_service.dart'; +import 'router/app_router.dart'; + +class GopeedApp extends ConsumerStatefulWidget { + const GopeedApp({super.key}); + + @override + ConsumerState createState() => _GopeedAppState(); +} + +class _GopeedAppState extends ConsumerState with WidgetsBindingObserver { + late final GoRouter _router; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _router = AppRouter.build(ref.read(webAuthControllerProvider)); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _router.dispose(); + super.dispose(); + } + + @override + void didChangePlatformBrightness() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + ref.listen(appRuntimeControllerProvider, (_, next) { + final runtime = next.value; + if (runtime != null) { + ref.read(appAppearanceControllerProvider.notifier).initialize(runtime.downloaderConfig.extra); + } + }); + final runtime = ref.watch(appRuntimeControllerProvider).value; + if (runtime != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(appAppearanceControllerProvider.notifier).initialize(runtime.downloaderConfig.extra); + }); + } + + final appearance = ref.watch(appAppearanceControllerProvider); + final themeColor = appearance.themeColor; + final isLightTheme = appearance.resolveIsLight(WidgetsBinding.instance.platformDispatcher.platformBrightness); + final themeMode = switch (appearance.themeMode) { + AppThemeMode.system => shad.ThemeMode.system, + AppThemeMode.light => shad.ThemeMode.light, + AppThemeMode.dark => shad.ThemeMode.dark, + }; + AppWindowCapabilityHost.instance.updateAppearance( + AppWindowAppearance( + themeMode: appearance.themeMode.key, + themeColor: appearance.themeColor.key, + locale: runtime?.downloaderConfig.extra.locale ?? '', + ), + ); + + return shad.ShadcnApp.router( + debugShowCheckedModeBanner: false, + onGenerateTitle: (context) => context.l10n.appTitle, + theme: AppTheme.light(themeColor), + darkTheme: AppTheme.dark(themeColor), + materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor), + themeMode: themeMode, + locale: supportedLocaleFromConfig(runtime?.downloaderConfig.extra.locale ?? ''), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) { + final content = AppComponentThemes(child: AppWindowFrame(child: child ?? const SizedBox.shrink())); + if (!WebViewRpcService.instance.supported) { + return content; + } + return Overlay( + initialEntries: [ + OverlayEntry(builder: (_) => content), + OverlayEntry(builder: (_) => const WebViewRpcOverlay()), + ], + ); + }, + routerConfig: _router, + ); + } +} diff --git a/ui/flutter/lib/app/application/android_foreground_service.dart b/ui/flutter/lib/app/application/android_foreground_service.dart new file mode 100644 index 00000000..479b7363 --- /dev/null +++ b/ui/flutter/lib/app/application/android_foreground_service.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; + +import '../../l10n/l10n.dart'; + +/// Keeps the Android process alive while Gopeed is available for downloads. +class AndroidForegroundService { + const AndroidForegroundService._(); + + static Future ensureRunning(AppLocalizations l10n) async { + if (!Platform.isAndroid) return; + + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'gopeed_service', + channelName: l10n.androidForegroundServiceChannel, + channelImportance: NotificationChannelImportance.LOW, + showWhen: true, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions(showNotification: false, playSound: false), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + autoRunOnBoot: true, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + + if (await FlutterForegroundTask.isRunningService) { + _throwOnFailure(await FlutterForegroundTask.restartService()); + return; + } + + _throwOnFailure( + await FlutterForegroundTask.startService( + notificationTitle: l10n.androidForegroundNotificationTitle, + notificationText: l10n.androidForegroundNotificationText, + ), + ); + } + + static void _throwOnFailure(ServiceRequestResult result) { + if (result case ServiceRequestFailure(:final error)) { + throw error; + } + } +} diff --git a/ui/flutter/lib/app/application/app_appearance_controller.dart b/ui/flutter/lib/app/application/app_appearance_controller.dart new file mode 100644 index 00000000..6342f9dd --- /dev/null +++ b/ui/flutter/lib/app/application/app_appearance_controller.dart @@ -0,0 +1,68 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/model/downloader_config.dart'; +import '../../shared/theme/app_theme_color.dart'; + +enum AppThemeMode { + system('system'), + light('light'), + dark('dark'); + + const AppThemeMode(this.key); + + final String key; + + static AppThemeMode fromKey(String? key) { + return values.where((mode) => mode.key == key).firstOrNull ?? AppThemeMode.system; + } +} + +class AppAppearanceState { + const AppAppearanceState({this.themeMode = AppThemeMode.system, this.themeColor = AppThemeColor.green}); + + final AppThemeMode themeMode; + final AppThemeColor themeColor; + + bool resolveIsLight(Brightness systemBrightness) { + return switch (themeMode) { + AppThemeMode.system => systemBrightness == Brightness.light, + AppThemeMode.light => true, + AppThemeMode.dark => false, + }; + } + + AppAppearanceState copyWith({AppThemeMode? themeMode, AppThemeColor? themeColor}) { + return AppAppearanceState(themeMode: themeMode ?? this.themeMode, themeColor: themeColor ?? this.themeColor); + } +} + +final appAppearanceControllerProvider = NotifierProvider( + AppAppearanceController.new, +); + +class AppAppearanceController extends Notifier { + bool _initialized = false; + + @override + AppAppearanceState build() => const AppAppearanceState(); + + void initialize(ExtraConfig config) { + if (_initialized) return; + _initialized = true; + state = AppAppearanceState( + themeMode: AppThemeMode.fromKey(config.themeMode), + themeColor: AppThemeColor.fromKey(config.themeColor), + ); + } + + void setThemeMode(AppThemeMode mode) { + _initialized = true; + state = state.copyWith(themeMode: mode); + } + + void setThemeColor(AppThemeColor color) { + _initialized = true; + state = state.copyWith(themeColor: color); + } +} diff --git a/ui/flutter/lib/app/application/app_deep_link_controller.dart b/ui/flutter/lib/app/application/app_deep_link_controller.dart new file mode 100644 index 00000000..c63ca5df --- /dev/null +++ b/ui/flutter/lib/app/application/app_deep_link_controller.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:share_handler/share_handler.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../api/model/create_task.dart'; +import '../../api/model/install_extension.dart'; +import '../../api/model/request.dart'; +import '../../core/entry/app_startup_options.dart'; +import '../../core/utils/content_uri_resolver.dart'; +import '../../core/window/app_window_launcher.dart'; +import '../../features/extensions/application/pending_extension_install.dart'; +import '../../features/tasks/application/pending_create_task.dart'; +import '../../util/util.dart'; +import '../router/app_router.dart'; +import 'app_runtime_controller.dart'; + +final appDeepLinkControllerProvider = AsyncNotifierProvider( + AppDeepLinkController.new, +); + +class AppDeepLinkState { + const AppDeepLinkState({this.started = false}); + + final bool started; +} + +class AppDeepLinkController extends AsyncNotifier { + StreamSubscription? _subscription; + StreamSubscription? _sharedMediaSubscription; + bool _started = false; + + @override + Future build() async { + final runtime = ref.watch(appRuntimeControllerProvider).value; + if (runtime == null || _started || kIsWeb) { + return AppDeepLinkState(started: _started); + } + _started = true; + final appLinks = AppLinks(); + _subscription = appLinks.uriLinkStream.listen((uri) { + unawaited(_handleUri(uri)); + }); + if (Util.isMobile()) { + final shareHandler = ShareHandlerPlatform.instance; + _sharedMediaSubscription = shareHandler.sharedMediaStream.listen((media) { + unawaited(_handleSharedMedia(media, ignoreContentUri: true)); + }); + try { + final media = await shareHandler.getInitialSharedMedia(); + if (media != null) { + await _handleSharedMedia(media); + } + } catch (_) {} + } + ref.onDispose(() { + _subscription?.cancel(); + _sharedMediaSubscription?.cancel(); + }); + try { + final initial = await appLinks.getInitialLink(); + if (initial != null) { + await _handleUri(initial); + } + } catch (_) {} + return const AppDeepLinkState(started: true); + } + + Future _handleSharedMedia(SharedMedia media, {bool ignoreContentUri = false}) async { + final uri = sharedMediaUri(media); + if (uri == null || (ignoreContentUri && uri.scheme == 'content')) return; + await _handleUri(uri); + } + + Future _handleUri(Uri uri) async { + if (uri.scheme == 'gopeed') { + await _handleGopeedUri(uri); + return; + } + final createTask = CreateTask(req: Request(url: await _uriToTaskUrl(uri))); + await _openCreate(createTask); + } + + Future _handleGopeedUri(Uri uri) async { + if (isSilentGopeedWakeUri(uri)) { + return; + } + final route = gopeedDeepLinkRoute(uri); + if (route == '/create') { + final params = uri.queryParameters['params']; + if (params?.isNotEmpty == true) { + await _openCreate(CreateTask.fromJson(_decodeParams(params!))); + return; + } + await _openCreate(null); + return; + } + + if (route == '/extension') { + final params = uri.queryParameters['params']; + if (params?.isNotEmpty == true) { + ref.read(pendingExtensionInstallProvider.notifier).set(InstallExtension.fromJson(_decodeParams(params!))); + } + await _go('/extensions'); + return; + } + + await _go('/'); + } + + Future _openCreate(CreateTask? createTask) async { + if (Util.isDesktop()) { + final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask); + if (!opened) { + ref.read(pendingCreateTaskProvider.notifier).set(createTask); + await _go('/create'); + } + return; + } + ref.read(pendingCreateTaskProvider.notifier).set(createTask); + await _go('/create'); + } + + Future _go(String route) async { + if (Util.isDesktop()) { + await windowManager.show(); + await windowManager.focus(); + } + final context = AppRouter.rootNavigatorKey.currentContext; + if (context != null && context.mounted) { + context.go(route); + } + } + + Map _decodeParams(String params) { + final safeParams = params.replaceAll('"', '').replaceAll(' ', '+'); + final paramsJson = String.fromCharCodes(base64Decode(base64.normalize(safeParams))); + return jsonDecode(paramsJson) as Map; + } + + Future _uriToTaskUrl(Uri uri) async { + if (uri.scheme == 'magnet' || uri.scheme == 'http' || uri.scheme == 'https') { + return uri.toString(); + } + if (uri.scheme == 'file') { + return Util.isWindows() ? Uri.decodeFull(uri.path.substring(1)) : uri.path; + } + if (uri.scheme == 'content' && Util.isAndroid()) { + return ContentUriResolver.copyToCache(uri); + } + throw ArgumentError.value(uri, 'uri', 'Unsupported task URI scheme'); + } +} + +/// Picks the actionable URI from content received through the platform share UI. +/// +/// File attachments take precedence over an optional text caption. Both the +/// Android and iOS share-handler implementations expose attachments as local +/// paths that are ready for the app to read. +Uri? sharedMediaUri(SharedMedia media) { + final attachments = media.attachments; + if (attachments != null) { + for (final attachment in attachments) { + final path = attachment?.path.trim(); + if (path != null && path.isNotEmpty) return Uri.file(path); + } + } + + final content = media.content?.trim(); + if (content == null || content.isEmpty) return null; + return Uri.tryParse(content); +} + +/// Gopeed's established links use `gopeed:///create`, where the action is the +/// URI path. Do not interpret the URI host as an action: the host-style +/// `gopeed://create` form is intentionally unsupported. +String gopeedDeepLinkRoute(Uri uri) { + return uri.path; +} diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart new file mode 100644 index 00000000..18aea31c --- /dev/null +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../api/model/task.dart'; +import '../../core/capabilities/app_capabilities.dart'; +import '../../l10n/l10n.dart'; +import '../../util/util.dart'; +import 'app_runtime_controller.dart'; + +final appNotificationControllerProvider = AsyncNotifierProvider( + AppNotificationController.new, +); + +class AppNotificationState { + const AppNotificationState({this.started = false}); + + final bool started; +} + +class AppNotificationController extends AsyncNotifier { + final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); + final Map _previousStatus = {}; + Timer? _timer; + var _notificationId = 0; + + @override + Future build() async { + final runtime = ref.watch(appRuntimeControllerProvider).value; + if (runtime == null || kIsWeb || !Util.isDesktop()) { + return const AppNotificationState(); + } + await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); + _startPolling(); + ref.onDispose(() { + _timer?.cancel(); + }); + return const AppNotificationState(started: true); + } + + Future _initNotifications(AppLocalizations locale) async { + const darwin = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + final linux = LinuxInitializationSettings( + defaultActionName: locale.open, + defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'), + ); + + String? windowsIconPath; + try { + if (Util.isWindows()) { + final byteData = await rootBundle.load('assets/icon/icon.ico'); + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/notification_icon.ico'); + await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)); + windowsIconPath = file.path; + } + } catch (_) {} + + final windows = WindowsInitializationSettings( + appName: 'Gopeed', + appUserModelId: 'com.gopeed.gopeed', + guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', + iconPath: windowsIconPath, + ); + + await _plugin.initialize( + settings: InitializationSettings(macOS: darwin, linux: linux, windows: windows), + ); + } + + void _startPolling() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 2), (_) async { + try { + final runtime = ref.read(appRuntimeControllerProvider).value; + if (runtime?.downloaderConfig.extra.desktopNotification == false) { + return; + } + final tasks = await ref.read(gopeedServiceProvider).getTasks(Status.values); + final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); + for (final task in tasks) { + final previous = _previousStatus[task.id]; + if (previous != null && previous != task.status) { + if (task.status == Status.done) { + await _showNotification(title: locale.notificationTaskDone, body: task.name); + } else if (task.status == Status.error) { + await _showNotification(title: locale.notificationTaskError, body: task.name); + } + } + _previousStatus[task.id] = task.status; + } + final currentIds = tasks.map((task) => task.id).toSet(); + _previousStatus.removeWhere((id, _) => !currentIds.contains(id)); + } catch (_) {} + }); + } + + Future _showNotification({required String title, required String body}) async { + const details = NotificationDetails( + macOS: DarwinNotificationDetails(), + linux: LinuxNotificationDetails(), + windows: WindowsNotificationDetails(), + ); + await _plugin.show(id: _notificationId++, title: title, body: body, notificationDetails: details); + } +} diff --git a/ui/flutter/lib/app/application/app_platform_controller.dart b/ui/flutter/lib/app/application/app_platform_controller.dart new file mode 100644 index 00000000..fd144762 --- /dev/null +++ b/ui/flutter/lib/app/application/app_platform_controller.dart @@ -0,0 +1,384 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:launch_at_startup/launch_at_startup.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../api/api.dart' as api; +import '../../api/model/downloader_config.dart'; +import '../../core/libgopeed_boot.dart'; +import '../../core/capabilities/app_capabilities.dart'; +import '../../core/window/app_window_launcher.dart'; +import '../../core/window/window_capability_transport.dart'; +import '../../database/database.dart'; +import '../../database/entity.dart'; +import '../../features/tasks/application/pending_create_task.dart'; +import '../../features/tasks/application/pending_update_task.dart'; +import '../../l10n/l10n.dart'; +import '../../util/package_info.dart'; +import '../../util/updater.dart'; +import '../../util/util.dart'; +import '../../util/browser_extension_host/browser_extension_host.dart'; +import '../../util/log_util.dart'; +import '../../util/scheme_register/scheme_register.dart'; +import '../rpc/host_rpc_service.dart'; +import '../router/app_router.dart'; +import 'app_runtime_controller.dart'; + +final appPlatformControllerProvider = AsyncNotifierProvider( + AppPlatformController.new, +); + +class AppPlatformState { + const AppPlatformState({ + this.started = false, + this.launchAtStartup = false, + this.runAsMenubarApp = false, + this.updateStatus = AppUpdateStatus.idle, + this.latestVersion, + this.updateError, + }); + + final bool started; + final bool launchAtStartup; + final bool runAsMenubarApp; + final AppUpdateStatus updateStatus; + final VersionInfo? latestVersion; + final String? updateError; +} + +enum AppUpdateStatus { idle, checking, upToDate, available, failed } + +class AppPlatformController extends AsyncNotifier with WindowListener, TrayListener { + bool _started = false; + String? _trayLocaleConfig; + bool _updateInitialized = false; + bool _launchAtStartup = false; + bool _runAsMenubarApp = false; + AppUpdateStatus _updateStatus = AppUpdateStatus.idle; + VersionInfo? _latestVersion; + String? _updateError; + Future? _checkUpdateFuture; + final _windowsResizeSave = Util.debounce(() async { + final size = await windowManager.getSize(); + Database.instance.saveWindowState(WindowStateEntity(width: size.width, height: size.height)); + }, 500); + + @override + Future build() async { + final runtime = ref.watch(appRuntimeControllerProvider).value; + if (runtime == null || kIsWeb) { + return _snapshot(); + } + if (!Util.isDesktop()) { + _scheduleInitialUpdateCheck(); + return _snapshot(); + } + if (_started) { + final localeConfig = runtime.downloaderConfig.extra.locale; + if (_trayLocaleConfig != localeConfig) await _initTray(); + return _snapshot(); + } + await _start(); + _scheduleInitialUpdateCheck(); + ref.onDispose(() { + windowManager.removeListener(this); + trayManager.removeListener(this); + unawaited(HostRpcService.instance.stop()); + unawaited(AppWindowCapabilityHost.instance.stop()); + }); + return _snapshot(); + } + + void _scheduleInitialUpdateCheck() { + if (_updateInitialized) return; + _updateInitialized = true; + Timer.run(() => unawaited(_initCheckUpdate())); + } + + Future _start() async { + _started = true; + await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry); + windowManager.addListener(this); + await windowManager.setPreventClose(true); + if (Util.isMacos()) { + _runAsMenubarApp = Database.instance.getRunAsMenubarApp(); + await windowManager.setSkipTaskbar(_runAsMenubarApp); + } + await _initTray(); + await _initHostRpc(); + unawaited(_initDesktopIntegrations()); + await _initLaunchAtStartup(); + } + + Future _initTray() async { + final localeConfig = ref.read(appRuntimeControllerProvider).value?.downloaderConfig.extra.locale ?? ''; + final locale = appLocalizationsFor(localeConfig); + if (Util.isWindows()) { + await trayManager.setIcon('assets/tray_icon/icon.ico'); + } else if (Util.isMacos()) { + await trayManager.setIcon('assets/tray_icon/icon_mac.png', isTemplate: true); + } else if (Platform.environment.containsKey('FLATPAK_ID') || Platform.environment.containsKey('SNAP')) { + await trayManager.setIcon('com.gopeed.Gopeed'); + } else { + await trayManager.setIcon('assets/tray_icon/icon.png'); + } + + final version = _safeVersionLabel(locale.version); + final menu = Menu( + items: [ + MenuItem(label: locale.show, onClick: (_) => _showMainWindow()), + MenuItem.separator(), + MenuItem(label: locale.create, onClick: (_) => _openCreateTask()), + MenuItem( + label: locale.startAll, + onClick: (_) => unawaited(ref.read(gopeedServiceProvider).continueAllTasks(null)), + ), + MenuItem( + label: locale.pauseAll, + onClick: (_) => unawaited(ref.read(gopeedServiceProvider).pauseAllTasks(null)), + ), + MenuItem(label: locale.setting, onClick: (_) => _go('/settings')), + MenuItem.separator(), + MenuItem( + label: locale.donate, + onClick: (_) => unawaited(launchUrl(Uri.parse('https://gopeed.com/docs/donate'))), + ), + MenuItem(label: version), + MenuItem.separator(), + MenuItem(label: locale.exit, onClick: (_) => _exit()), + ], + ); + + if (!Util.isLinux()) { + await trayManager.setToolTip('Gopeed'); + } + await trayManager.setContextMenu(menu); + trayManager.removeListener(this); + trayManager.addListener(this); + _trayLocaleConfig = localeConfig; + } + + Future _initHostRpc() async { + await HostRpcService.instance.start( + onCreate: (createTask, silent) async { + final pendingUpdate = ref.read(pendingUpdateTaskProvider); + if (pendingUpdate != null && createTask.req != null) { + ref + .read(pendingUpdateRequestProvider.notifier) + .set(PendingUpdateRequest(task: pendingUpdate, createTask: createTask)); + await _showMainWindow(); + return; + } + if (silent) { + await ref.read(gopeedServiceProvider).createTask(createTask); + return; + } + final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask); + if (!opened) { + ref.read(pendingCreateTaskProvider.notifier).set(createTask); + await _go('/create'); + } + }, + onForward: ({required path, required method, data, query}) { + return api.forward(path, method: method, data: data, queryParameters: query); + }, + ); + } + + Future _initDesktopIntegrations() async { + try { + registerUrlScheme('gopeed'); + final runtime = ref.read(appRuntimeControllerProvider).value; + if (runtime?.downloaderConfig.extra.defaultBtClient == true) { + registerDefaultTorrentClient(); + } + } catch (error, stackTrace) { + logger.w('register scheme failed', error, stackTrace); + } + + try { + await installHost(); + for (final browser in Browser.values) { + await installManifest(browser); + } + } catch (error, stackTrace) { + logger.w('browser extension host integration failed', error, stackTrace); + } + + try { + await installUpdater(); + } catch (error, stackTrace) { + // Native helper assets are generated by release builds, so their absence + // in a source/debug bundle must not prevent the app from starting. + logger.w('updater integration failed', error, stackTrace); + } + } + + Future _initLaunchAtStartup() async { + if (!Util.isWindows() && !Util.isLinux()) { + return; + } + launchAtStartup.setup(appName: packageInfo.appName, appPath: Platform.resolvedExecutable, args: const ['--hidden']); + _launchAtStartup = await launchAtStartup.isEnabled(); + } + + Future setLaunchAtStartup(bool enabled) async { + if (!Util.isWindows() && !Util.isLinux()) { + return; + } + if (enabled) { + await launchAtStartup.enable(); + } else { + await launchAtStartup.disable(); + } + _launchAtStartup = enabled; + _emit(); + } + + Future setRunAsMenubarApp(bool enabled) async { + if (!Util.isMacos()) { + return; + } + Database.instance.saveRunAsMenubarApp(enabled); + _runAsMenubarApp = enabled; + await windowManager.setSkipTaskbar(enabled); + await Future.delayed(const Duration(milliseconds: 100)); + await _showMainWindow(); + _emit(); + } + + Future _initCheckUpdate() async { + final runtime = ref.read(appRuntimeControllerProvider).value; + if (runtime?.downloaderConfig.extra.notifyWhenNewVersion == false) return; + await checkForUpdate(); + } + + Future checkForUpdate() { + return _checkUpdateFuture ??= _performUpdateCheck().whenComplete(() => _checkUpdateFuture = null); + } + + Future _performUpdateCheck() async { + _updateStatus = AppUpdateStatus.checking; + _updateError = null; + _emit(); + try { + _latestVersion = await checkUpdate(); + _updateStatus = _latestVersion == null ? AppUpdateStatus.upToDate : AppUpdateStatus.available; + _emit(); + return _latestVersion; + } catch (error, stackTrace) { + _latestVersion = null; + _updateStatus = AppUpdateStatus.failed; + _updateError = error.toString(); + _emit(); + logger.w('check update failed', error, stackTrace); + return null; + } + } + + Future startUpdate(VersionInfo versionInfo, UpdateProgressCallback onProgress) async { + final runtime = ref.read(appRuntimeControllerProvider).value; + await updateApp( + versionInfo, + githubMirror: runtime?.downloaderConfig.extra.githubMirror ?? ExtraConfigGithubMirror(enabled: false), + onProgress: onProgress, + ); + } + + AppPlatformState _snapshot() => AppPlatformState( + started: _started, + launchAtStartup: _launchAtStartup, + runAsMenubarApp: _runAsMenubarApp, + updateStatus: _updateStatus, + latestVersion: _latestVersion, + updateError: _updateError, + ); + + void _emit() { + state = AsyncValue.data(_snapshot()); + } + + String _safeVersionLabel(String label) { + try { + return '$label($appVersion)'; + } catch (_) { + return label; + } + } + + Future _showMainWindow() async { + await windowManager.show(); + await windowManager.focus(); + } + + Future _openCreateTask() async { + await _showMainWindow(); + final opened = await AppWindowLauncher.openCreateTaskWindow(); + if (!opened) { + _go('/create'); + } + } + + Future _go(String route) async { + final context = AppRouter.rootNavigatorKey.currentContext; + await _showMainWindow(); + if (context != null && context.mounted) { + context.go(route); + } + } + + Future _exit() async { + try { + await HostRpcService.instance.stop(); + await AppWindowCapabilityHost.instance.stop(); + await LibgopeedBoot.instance.stop(); + } catch (_) {} + await windowManager.destroy(); + } + + @override + void onWindowClose() async { + final isPreventClose = await windowManager.isPreventClose(); + if (isPreventClose) { + await windowManager.hide(); + } + } + + @override + void onWindowFocus() { + if (Util.isMacos() && _runAsMenubarApp) { + unawaited(windowManager.setSkipTaskbar(true)); + } + } + + @override + void onTrayIconMouseDown() { + unawaited(_showMainWindow()); + } + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + @override + void onWindowMaximize() { + Database.instance.saveWindowState(WindowStateEntity(isMaximized: true)); + } + + @override + void onWindowUnmaximize() { + Database.instance.saveWindowState(WindowStateEntity(isMaximized: false)); + } + + @override + void onWindowResize() { + _windowsResizeSave(); + } +} diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart new file mode 100644 index 00000000..20bc04a3 --- /dev/null +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../../api/api.dart' as api; +import '../../api/model/downloader_config.dart'; +import '../../core/capabilities/app_capabilities.dart'; +import '../../core/common/start_config.dart'; +import '../../core/entry/app_initializer.dart'; +import '../../core/libgopeed_boot.dart'; +import '../../database/database.dart'; +import '../../database/entity.dart'; +import '../../features/auth/application/web_auth_controller.dart'; +import '../../l10n/l10n.dart'; +import '../../util/log_util.dart'; +import '../../util/package_info.dart'; +import '../../util/util.dart'; +import '../rpc/webview_rpc_service.dart'; +import 'android_foreground_service.dart'; +import 'location_keep_alive.dart'; + +const unixSocketPath = 'gopeed.sock'; + +final appRuntimeControllerProvider = AsyncNotifierProvider( + AppRuntimeController.new, +); + +class AppRuntimeState { + const AppRuntimeState({ + required this.startConfig, + required this.runningPort, + required this.downloaderConfig, + this.localBackendStarted = false, + this.startupError, + }); + + final StartConfig startConfig; + final int runningPort; + final DownloaderConfig downloaderConfig; + final bool localBackendStarted; + final Object? startupError; + + String runningAddress() { + if (startConfig.network == 'unix') { + return startConfig.address; + } + return '${startConfig.address.split(':').first}:$runningPort'; + } +} + +class AppRuntimeController extends AsyncNotifier { + static StartConfig? _defaultStartConfig; + + @override + Future build() async { + ref.onDispose(() => unawaited(WebViewRpcService.instance.stop())); + return _init(); + } + + Future reloadConfig() async { + final current = state.value; + if (current == null) return; + state = AsyncValue.data( + AppRuntimeState( + startConfig: current.startConfig, + runningPort: current.runningPort, + downloaderConfig: await ref.read(gopeedServiceProvider).getConfig(), + localBackendStarted: current.localBackendStarted, + startupError: current.startupError, + ), + ); + } + + void replaceDownloaderConfig(DownloaderConfig config) { + final current = state.value; + if (current == null) return; + state = AsyncValue.data( + AppRuntimeState( + startConfig: current.startConfig, + runningPort: current.runningPort, + downloaderConfig: DownloaderConfig.fromJson(config.toJson()), + localBackendStarted: current.localBackendStarted, + startupError: current.startupError, + ), + ); + unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive)); + } + + Future _init() async { + await AppInitializer.ensureDatabaseInitialized(); + try { + await initPackageInfo(); + } catch (_) {} + initLogger(); + + final startConfig = await _loadStartConfig(); + try { + startConfig.webViewRpcConfig = await WebViewRpcService.instance.start(); + } catch (error, stackTrace) { + logger.w('webview RPC initialization failed', error, stackTrace); + } + Object? startupError; + var runningPort = 0; + var localBackendStarted = false; + try { + // Go's rest.Start is already idempotent: when its server is running it + // returns the existing port before rebuilding or touching the Unix + // socket. This is also what keeps Riverpod hot reload safe. + runningPort = await LibgopeedBoot.instance.start(startConfig); + localBackendStarted = true; + } catch (error, stackTrace) { + startupError = error; + logger.w('libgopeed init failed, falling back to external API', error, stackTrace); + if (startConfig.network == 'unix') { + startConfig.network = 'tcp'; + startConfig.address = '127.0.0.1:9999'; + } + runningPort = int.tryParse(startConfig.address.split(':').last) ?? 9999; + } + + api.init( + startConfig.network, + _runningAddress(startConfig, runningPort), + startConfig.apiToken, + onUnauthorized: ref.read(webAuthControllerProvider).requireLogin, + ); + + final config = await _loadDownloaderConfig(); + unawaited(_initTrackerUpdate(config)); + unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale))); + + final result = AppRuntimeState( + startConfig: startConfig, + runningPort: runningPort, + downloaderConfig: config, + localBackendStarted: localBackendStarted, + startupError: startupError, + ); + if (Util.isIOS()) { + LocationKeepAliveCoordinator.instance.start( + () => + state.value?.downloaderConfig.extra.backgroundLocationKeepAlive ?? config.extra.backgroundLocationKeepAlive, + ); + } + return result; + } + + Future _initAndroidForegroundService(AppLocalizations l10n) async { + try { + await AndroidForegroundService.ensureRunning(l10n); + } catch (error, stackTrace) { + logger.w('Android foreground service initialization failed', error, stackTrace); + } + } + + Future _loadStartConfig() async { + final defaultCfg = await _initDefaultStartConfig(); + final saved = Database.instance.getStartConfig(); + final cfg = StartConfig() + ..network = saved?.network ?? defaultCfg.network + ..address = saved?.address ?? defaultCfg.address + ..apiToken = saved?.apiToken ?? defaultCfg.apiToken + ..storage = defaultCfg.storage + ..storageDir = defaultCfg.storageDir + ..refreshInterval = defaultCfg.refreshInterval; + return cfg; + } + + Future _initDefaultStartConfig() async { + if (_defaultStartConfig != null) { + return _defaultStartConfig!; + } + final cfg = StartConfig() + ..storage = 'bolt' + ..storageDir = Util.getStorageDir() + ..refreshInterval = 0 + ..apiToken = ''; + if (!Util.supportUnixSocket()) { + cfg + ..network = 'tcp' + ..address = '127.0.0.1:0'; + } else { + cfg + ..network = 'unix' + ..address = '${(await getTemporaryDirectory()).path}/$unixSocketPath'; + } + _defaultStartConfig = cfg; + return cfg; + } + + Future _loadDownloaderConfig() async { + DownloaderConfig config; + try { + config = await ref.read(gopeedServiceProvider).getConfig(); + } catch (error, stackTrace) { + logger.w('load downloader config failed', error, stackTrace); + config = DownloaderConfig(); + } + await _initDownloaderConfig(config); + return config; + } + + Future _initDownloaderConfig(DownloaderConfig config) async { + final extra = config.extra; + if (extra.themeMode.isEmpty) { + extra.themeMode = 'system'; + } + if (extra.themeColor.isEmpty) { + extra.themeColor = 'green'; + } + if (extra.bt.trackerSubscribeUrls.isEmpty) { + extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls); + } + if (config.proxy.scheme.isEmpty) { + config.proxy.scheme = 'http'; + } + if (config.downloadDir.isEmpty) { + config.downloadDir = await _defaultDownloadDir(); + } + if (extra.downloadCategories.isEmpty) { + extra.downloadCategories = [ + DownloadCategory( + name: '', + path: path.join(config.downloadDir, 'Music'), + isBuiltIn: true, + nameKey: 'categoryMusic', + ), + DownloadCategory( + name: '', + path: path.join(config.downloadDir, 'Video'), + isBuiltIn: true, + nameKey: 'categoryVideo', + ), + DownloadCategory( + name: '', + path: path.join(config.downloadDir, 'Document'), + isBuiltIn: true, + nameKey: 'categoryDocument', + ), + DownloadCategory( + name: '', + path: path.join(config.downloadDir, 'Program'), + isBuiltIn: true, + nameKey: 'categoryProgram', + ), + ]; + } + if (extra.githubMirror.mirrors.isEmpty) { + extra.githubMirror.mirrors = [ + GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true), + GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true), + ]; + } + } + + Future _initTrackerUpdate(DownloaderConfig config) async { + final btExtra = config.extra.bt; + final lastUpdateTime = btExtra.lastTrackerUpdateTime; + if (!btExtra.autoUpdateTrackers || + (lastUpdateTime != null && !lastUpdateTime.isBefore(DateTime.now().subtract(const Duration(days: 1))))) { + return; + } + try { + await updateTrackers(config); + } catch (error, stackTrace) { + logger.w('tracker update failed', error, stackTrace); + } + } + + Future updateTrackers(DownloaderConfig config) async { + final btExtra = config.extra.bt; + final trackers = []; + for (final url in btExtra.trackerSubscribeUrls) { + final resp = await api.proxyRequest(url); + final data = resp.data; + if (resp.statusCode == 200 && data != null && data.isNotEmpty) { + trackers.addAll(const LineSplitter().convert(data).where((line) => line.trim().isNotEmpty)); + } + } + btExtra.subscribeTrackers = trackers; + btExtra.lastTrackerUpdateTime = DateTime.now(); + config.protocolConfig.bt.trackers = {...btExtra.subscribeTrackers, ...btExtra.customTrackers}.toList(); + await ref.read(gopeedServiceProvider).putConfig(config); + + final current = state.value; + if (current != null) { + state = AsyncValue.data( + AppRuntimeState( + startConfig: current.startConfig, + runningPort: current.runningPort, + downloaderConfig: config, + localBackendStarted: current.localBackendStarted, + startupError: current.startupError, + ), + ); + } + } + + Future _defaultDownloadDir() async { + if (kIsWeb) { + return './'; + } + if (Util.isDesktop()) { + return (await getDownloadsDirectory())?.path ?? './'; + } + if (Util.isAndroid()) { + return (await getExternalStorageDirectory())?.path ?? (await getApplicationDocumentsDirectory()).path; + } + if (Util.isIOS()) { + return (await getApplicationDocumentsDirectory()).path; + } + return './'; + } + + String _runningAddress(StartConfig config, int runningPort) { + if (config.network == 'unix') { + return config.address; + } + return '${config.address.split(':').first}:$runningPort'; + } + + Future saveStartConfig(StartConfig config) async { + Database.instance.saveStartConfig( + StartConfigEntity(network: config.network, address: config.address, apiToken: config.apiToken), + ); + } +} + +const allTrackerSubscribeUrls = [ + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt', + 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt', + 'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt', + 'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt', + 'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt', +]; diff --git a/ui/flutter/lib/app/application/locale_controller.dart b/ui/flutter/lib/app/application/locale_controller.dart new file mode 100644 index 00000000..6e6a2869 --- /dev/null +++ b/ui/flutter/lib/app/application/locale_controller.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +class LocaleController extends ChangeNotifier { + Locale _locale = const Locale('en'); + + Locale get locale => _locale; + + void setLocale(Locale locale) { + if (_locale == locale) return; + _locale = locale; + notifyListeners(); + } +} diff --git a/ui/flutter/lib/app/application/location_keep_alive.dart b/ui/flutter/lib/app/application/location_keep_alive.dart new file mode 100644 index 00000000..09240ea7 --- /dev/null +++ b/ui/flutter/lib/app/application/location_keep_alive.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../api/api.dart' as api; +import '../../api/model/task.dart'; + +class LocationKeepAlive { + const LocationKeepAlive._(); + + static const _channel = MethodChannel('gopeed/location_keep_alive'); + + static Future requestPermission() async { + if (!Platform.isIOS) return false; + try { + return await _channel.invokeMethod('requestPermission') ?? false; + } catch (_) { + return false; + } + } + + static Future start() async { + if (Platform.isIOS) await _channel.invokeMethod('start'); + } + + static Future stop() async { + if (Platform.isIOS) await _channel.invokeMethod('stop'); + } +} + +class LocationKeepAliveCoordinator { + LocationKeepAliveCoordinator._(); + + static final instance = LocationKeepAliveCoordinator._(); + + Timer? _timer; + bool Function()? _enabled; + bool _reconciling = false; + + void start(bool Function() enabled) { + if (!Platform.isIOS) return; + _enabled = enabled; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 10), (_) => unawaited(reconcile())); + unawaited(reconcile()); + } + + Future reconcile({bool? enabled}) async { + if (!Platform.isIOS || _reconciling) return; + _reconciling = true; + try { + if (!(enabled ?? _enabled?.call() ?? false)) { + await LocationKeepAlive.stop(); + return; + } + final running = await api.getTasks([Status.running]); + running.isEmpty ? await LocationKeepAlive.stop() : await LocationKeepAlive.start(); + } catch (_) { + // Keep the current native state when task status cannot be queried. + } finally { + _reconciling = false; + } + } +} diff --git a/ui/flutter/lib/app/child_window_app.dart b/ui/flutter/lib/app/child_window_app.dart new file mode 100644 index 00000000..e38c30cb --- /dev/null +++ b/ui/flutter/lib/app/child_window_app.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../api/model/create_task.dart'; +import '../app/application/app_appearance_controller.dart'; +import '../core/window/app_window_bootstrap.dart'; +import '../core/window/app_window_frame.dart'; +import '../core/window/app_window_payload.dart'; +import '../core/window/window_capability_transport.dart'; +import '../features/tasks/presentation/pages/create_task_window_page.dart'; +import '../l10n/l10n.dart'; +import '../shared/theme/app_component_themes.dart'; +import '../shared/theme/app_theme.dart'; +import '../shared/theme/app_theme_color.dart'; + +class ChildWindowApp extends StatefulWidget { + const ChildWindowApp({super.key, required this.payload, required this.session}); + + final AppWindowPayload payload; + final ChildWindowSession session; + + @override + State createState() => _ChildWindowAppState(); +} + +class _ChildWindowAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + widget.session.appearance.addListener(_appearanceChanged); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + widget.session.appearance.removeListener(_appearanceChanged); + widget.session.dispose(); + super.dispose(); + } + + @override + void didChangePlatformBrightness() { + setState(() {}); + } + + void _appearanceChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final appearance = widget.session.appearance.value; + final themeColor = AppThemeColor.fromKey(appearance.themeColor); + final themeMode = AppThemeMode.fromKey(appearance.themeMode); + final isLightTheme = switch (themeMode) { + AppThemeMode.system => WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.light, + AppThemeMode.light => true, + AppThemeMode.dark => false, + }; + final shadThemeMode = switch (themeMode) { + AppThemeMode.system => shad.ThemeMode.system, + AppThemeMode.light => shad.ThemeMode.light, + AppThemeMode.dark => shad.ThemeMode.dark, + }; + final locale = supportedLocaleFromConfig(appearance.locale); + + return shad.ShadcnApp( + debugShowCheckedModeBanner: false, + onGenerateTitle: (context) => AppWindowBootstrap.subWindowTitle(widget.payload.type, context.l10n), + theme: AppTheme.light(themeColor), + darkTheme: AppTheme.dark(themeColor), + materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor), + themeMode: shadThemeMode, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), + home: AppWindowFrame(child: Builder(builder: _buildPage)), + ); + } + + Widget _buildPage(BuildContext context) { + return switch (widget.payload.type) { + AppWindowType.createTask => CreateTaskWindowPage( + windowController: widget.session.controller, + initialTask: widget.payload.createTask == null ? null : CreateTask.fromJson(widget.payload.createTask!), + ), + _ => shad.Scaffold(child: Center(child: Text(context.l10n.unsupportedWindowPayload))), + }; + } +} diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart new file mode 100644 index 00000000..29a1d9a9 --- /dev/null +++ b/ui/flutter/lib/app/router/app_router.dart @@ -0,0 +1,100 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/extensions/presentation/pages/extensions_page.dart'; +import '../../features/extensions/presentation/pages/extension_details_page.dart'; +import '../../features/extensions/application/extensions_controller.dart'; +import '../../features/auth/application/web_auth_controller.dart'; +import '../../features/auth/presentation/pages/login_page.dart'; +import '../../features/home/presentation/pages/home_page.dart'; +import '../../features/settings/presentation/pages/settings_page.dart'; +import '../../features/tasks/presentation/pages/create_task_window_page.dart'; +import '../../features/tasks/domain/task_record.dart'; +import '../../features/tasks/presentation/pages/task_details_page.dart'; +import '../../features/tasks/presentation/pages/task_files_page.dart'; +import 'shells/main_shell.dart'; + +class AppRouter { + static final GlobalKey rootNavigatorKey = GlobalKey(); + + static GoRouter build(WebAuthController webAuthController) { + return GoRouter( + navigatorKey: rootNavigatorKey, + refreshListenable: webAuthController, + redirect: (context, state) { + final onLoginPage = state.matchedLocation == '/login'; + if (webAuthController.loginRequired) { + if (onLoginPage) return null; + return Uri(path: '/login', queryParameters: {'from': state.uri.toString()}).toString(); + } + if (onLoginPage) { + return _safeReturnLocation(state.uri.queryParameters['from']); + } + return null; + }, + routes: [ + GoRoute(path: '/login', parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const LoginPage()), + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomePage(), + routes: [ + GoRoute(path: 'create', builder: (context, state) => const CreateTaskWindowPage()), + GoRoute( + path: 'tasks/:id', + builder: (context, state) => TaskDetailsPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), + routes: [ + GoRoute( + path: 'files', + builder: (context, state) => TaskFilesPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), + ), + ], + ), + ], + ), + GoRoute( + path: '/extensions', + builder: (context, state) => const ExtensionsPage(), + routes: [ + GoRoute( + path: ':id', + builder: (context, state) => ExtensionDetailsPage( + extensionId: state.pathParameters['id'] ?? '', + initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, + ), + ), + ], + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + routes: [ + GoRoute( + path: ':section', + builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), + ), + ], + ), + ], + ), + ], + ); + } + + static String _safeReturnLocation(String? location) { + if (location == null || location.isEmpty) return '/'; + final uri = Uri.tryParse(location); + if (uri == null || uri.hasScheme || uri.host.isNotEmpty || !location.startsWith('/') || uri.path == '/login') { + return '/'; + } + return location; + } +} diff --git a/ui/flutter/lib/app/router/shells/main_shell.dart b/ui/flutter/lib/app/router/shells/main_shell.dart new file mode 100644 index 00000000..7b5e560c --- /dev/null +++ b/ui/flutter/lib/app/router/shells/main_shell.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../api/model/create_task.dart'; +import '../../application/app_deep_link_controller.dart'; +import '../../application/app_notification_controller.dart'; +import '../../application/app_runtime_controller.dart'; +import '../../application/app_platform_controller.dart'; +import '../../../features/settings/presentation/widgets/app_update_dialog.dart'; +import '../../../features/tasks/application/pending_create_task.dart'; +import '../../../features/tasks/application/pending_update_task.dart'; +import '../../../features/tasks/application/tasks_controller.dart'; +import '../../../features/tasks/presentation/widgets/pending_update_dialog.dart'; +import '../../../l10n/l10n.dart'; +import '../../../core/window/app_window_launcher.dart'; +import '../../../core/utils/breakpoints.dart'; +import '../../../shared/navigation/app_exit_confirmation_controller.dart'; +import '../../../shared/widgets/app_toast.dart'; + +class MainShell extends ConsumerStatefulWidget { + const MainShell({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => _MainShellState(); +} + +class _MainShellState extends ConsumerState { + String? _shownUpdateVersion; + bool _handlingPendingUpdateRequest = false; + final _exitConfirmationController = AppExitConfirmationController(); + + @override + Widget build(BuildContext context) { + ref.listen(appPlatformControllerProvider, (_, next) { + if (!kReleaseMode) return; + final update = next.value; + final version = update?.latestVersion; + if (update?.updateStatus != AppUpdateStatus.available || + version == null || + _shownUpdateVersion == version.version) { + return; + } + _shownUpdateVersion = version.version; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited( + showAppUpdateDialog( + context, + versionInfo: version, + onUpdate: ref.read(appPlatformControllerProvider.notifier).startUpdate, + ), + ); + }); + }); + ref.listen(pendingUpdateRequestProvider, (_, next) { + if (next != null) { + _schedulePendingUpdateRequest(next); + } + }); + final runtime = ref.watch(appRuntimeControllerProvider); + final content = runtime.when( + loading: () => const shad.Scaffold(child: Center(child: shad.CircularProgressIndicator())), + error: (error, _) => shad.Scaffold( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), + ), + ), + ), + data: (_) { + ref.watch(appPlatformControllerProvider); + ref.watch(appDeepLinkControllerProvider); + ref.watch(appNotificationControllerProvider); + return widget.child; + }, + ); + return _buildMobileBackGuard(context, content); + } + + Widget _buildMobileBackGuard(BuildContext context, Widget child) { + final isAndroidMobile = + !kIsWeb && + defaultTargetPlatform == TargetPlatform.android && + MediaQuery.sizeOf(context).width < Breakpoints.mobile; + if (!isAndroidMobile || context.canPop()) return child; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_exitConfirmationController.handleBack(context)); + }, + child: child, + ); + } + + void _schedulePendingUpdateRequest(PendingUpdateRequest request) { + if (_handlingPendingUpdateRequest) return; + _handlingPendingUpdateRequest = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + _handlingPendingUpdateRequest = false; + return; + } + unawaited(_handlePendingUpdateRequest(request)); + }); + } + + Future _handlePendingUpdateRequest(PendingUpdateRequest request) async { + try { + final decision = await showPendingUpdateDialog(context, taskName: request.task.name); + if (!mounted) return; + _clearPendingUpdateRequest(request); + switch (decision) { + case PendingUpdateDecision.updateTask: + final updateRequest = request.createTask.req; + if (updateRequest == null) return; + await ref.read(tasksControllerProvider.notifier).updateRequest(request.task.id, updateRequest); + final listeningTask = ref.read(pendingUpdateTaskProvider); + if (listeningTask?.id == request.task.id) { + ref.read(pendingUpdateTaskProvider.notifier).clear(); + } + break; + case PendingUpdateDecision.createTask: + await _openCapturedCreateTask(request.createTask); + break; + case PendingUpdateDecision.cancel || null: + break; + } + } catch (error) { + if (mounted) { + showAppToast(context, error.toString(), type: AppToastType.error); + } + } finally { + _handlingPendingUpdateRequest = false; + if (mounted) { + _clearPendingUpdateRequest(request); + final nextRequest = ref.read(pendingUpdateRequestProvider); + if (nextRequest != null) { + _schedulePendingUpdateRequest(nextRequest); + } + } + } + } + + void _clearPendingUpdateRequest(PendingUpdateRequest request) { + if (identical(ref.read(pendingUpdateRequestProvider), request)) { + ref.read(pendingUpdateRequestProvider.notifier).clear(); + } + } + + Future _openCapturedCreateTask(CreateTask createTask) async { + final opened = await AppWindowLauncher.openCreateTaskWindow(createTask: createTask); + if (!opened && mounted) { + ref.read(pendingCreateTaskProvider.notifier).set(createTask); + context.go('/create'); + } + } +} diff --git a/ui/flutter/lib/app/rpc/host_rpc_service.dart b/ui/flutter/lib/app/rpc/host_rpc_service.dart index fc8ada44..3ae260c8 100644 --- a/ui/flutter/lib/app/rpc/host_rpc_service.dart +++ b/ui/flutter/lib/app/rpc/host_rpc_service.dart @@ -6,17 +6,10 @@ import '../../api/model/create_task.dart'; import '../../api/model/result.dart'; import 'server.dart'; -typedef HostCreateHandler = Future Function( - CreateTask createTask, - bool silent, -); +typedef HostCreateHandler = Future Function(CreateTask createTask, bool silent); -typedef HostForwardHandler = Future Function({ - required String path, - required String method, - dynamic data, - Map? query, -}); +typedef HostForwardHandler = + Future Function({required String path, required String method, dynamic data, Map? query}); class HostRpcService { HostRpcService._(); @@ -25,18 +18,14 @@ class HostRpcService { RpcServerHandle? _server; - Future start({ - required HostCreateHandler onCreate, - required HostForwardHandler onForward, - }) async { + Future start({required HostCreateHandler onCreate, required HostForwardHandler onForward}) async { if (_server != null) { return; } _server = await startRpcServer( routes: { '/create': (ctx) async { - final meta = - ctx.request.headers['X-Gopeed-Host-Meta']?.firstOrNull ?? '{}'; + final meta = ctx.request.headers['X-Gopeed-Host-Meta']?.firstOrNull ?? '{}'; final jsonMeta = jsonDecode(meta); final silent = jsonMeta['silent'] as bool? ?? false; final params = await ctx.readText(); @@ -50,12 +39,7 @@ class HostRpcService { final path = (body['path'] as String?) ?? '/'; final data = body['data']; final query = body['query'] as Map?; - final response = await onForward( - path: path, - method: method, - data: data, - query: query, - ); + final response = await onForward(path: path, method: method, data: data, query: query); if (response is Map) { await ctx.writeJSON(response); } else { @@ -83,8 +67,7 @@ class HostRpcService { Map _decodeParams(String params) { final safeParams = params.replaceAll('"', '').replaceAll(' ', '+'); - final paramsJson = - String.fromCharCodes(base64Decode(base64.normalize(safeParams))); + final paramsJson = String.fromCharCodes(base64Decode(base64.normalize(safeParams))); return jsonDecode(paramsJson); } } diff --git a/ui/flutter/lib/app/rpc/server.dart b/ui/flutter/lib/app/rpc/server.dart index 8a5a6195..abcca512 100644 --- a/ui/flutter/lib/app/rpc/server.dart +++ b/ui/flutter/lib/app/rpc/server.dart @@ -69,10 +69,7 @@ class RpcBinding { final String network; final String address; - const RpcBinding({ - required this.network, - required this.address, - }); + const RpcBinding({required this.network, required this.address}); } class RpcServerHandle { @@ -80,50 +77,32 @@ class RpcServerHandle { final HttpServer server; final Future Function()? _cleanup; - RpcServerHandle({ - required this.binding, - required this.server, - Future Function()? cleanup, - }) : _cleanup = cleanup; + RpcServerHandle({required this.binding, required this.server, Future Function()? cleanup}) : _cleanup = cleanup; Future close({bool force = true}) async { await server.close(force: force); - if (_cleanup != null) { - await _cleanup!(); + final cleanup = _cleanup; + if (cleanup != null) { + await cleanup(); } } } Future defaultHostRpcBinding() async { if (Util.isWindows()) { - return const RpcBinding( - network: 'pipe', - address: r'\\.\pipe\gopeed_host', - ); + return const RpcBinding(network: 'pipe', address: r'\\.\pipe\gopeed_host'); } - return RpcBinding( - network: 'unix', - address: await Util.homePathJoin('gopeed_host.sock'), - ); + return RpcBinding(network: 'unix', address: await Util.homePathJoin('gopeed_host.sock')); } Future defaultWebViewRpcBinding() async { if (Util.supportUnixSocket()) { - return RpcBinding( - network: 'unix', - address: await Util.homePathJoin('gopeed_webview.sock'), - ); + return RpcBinding(network: 'unix', address: await Util.homePathJoin('gopeed_webview.sock')); } - return const RpcBinding( - network: 'tcp', - address: '127.0.0.1:0', - ); + return const RpcBinding(network: 'tcp', address: '127.0.0.1:0'); } -Future startRpcServer({ - RpcBinding? binding, - Map? routes, -}) async { +Future startRpcServer({RpcBinding? binding, Map? routes}) async { final rpcBinding = binding ?? await defaultHostRpcBinding(); Future Function()? cleanup; late final HttpServer httpServer; @@ -190,9 +169,5 @@ Future startRpcServer({ ); } - return RpcServerHandle( - binding: resolvedBinding, - server: httpServer, - cleanup: cleanup, - ); + return RpcServerHandle(binding: resolvedBinding, server: httpServer, cleanup: cleanup); } diff --git a/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart b/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart index f3943d71..97ae07cd 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart @@ -28,11 +28,7 @@ class _WebViewRpcOverlayState extends State { } return IgnorePointer( ignoring: false, - child: Stack( - children: pages - .map((page) => _WebViewRpcPageView(page: page)) - .toList(growable: false), - ), + child: Stack(children: pages.map((page) => _WebViewRpcPageView(page: page)).toList(growable: false)), ); }, ); @@ -65,12 +61,7 @@ class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - blurRadius: 24, - color: Colors.black26, - ), - ], + boxShadow: const [BoxShadow(blurRadius: 24, color: Colors.black26)], ), child: Column( children: [ diff --git a/ui/flutter/lib/app/rpc/webview_rpc_service.dart b/ui/flutter/lib/app/rpc/webview_rpc_service.dart index 486ddae8..653e78e6 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_service.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_service.dart @@ -69,12 +69,11 @@ class WebViewRpcService { static final WebViewRpcService instance = WebViewRpcService._(); - final ValueNotifier> pages = - ValueNotifier>([]); + final ValueNotifier> pages = ValueNotifier>([]); final Map _pagesById = {}; final Completer _overlayReady = Completer(); - final CookieManager _cookieManager = CookieManager.instance(); + late final CookieManager _cookieManager = CookieManager.instance(); RpcServerHandle? _server; int _pageSeq = 0; @@ -95,12 +94,7 @@ class WebViewRpcService { return _toConfig(_server!.binding); } final binding = await defaultWebViewRpcBinding(); - _server = await startRpcServer( - binding: binding, - routes: { - '/webview': _handleWebViewRequest, - }, - ); + _server = await startRpcServer(binding: binding, routes: {'/webview': _handleWebViewRequest}); return _toConfig(_server!.binding); } @@ -121,20 +115,13 @@ class WebViewRpcService { await _overlayReady.future; final body = await ctx.readJSON(); final method = body['method'] as String? ?? ''; - final params = (body['params'] as Map?)?.cast() ?? - const {}; + final params = (body['params'] as Map?)?.cast() ?? const {}; try { final result = await _dispatch(method, params); - await ctx.writeJSON({ - 'result': result, - 'error': null, - }); + await ctx.writeJSON({'result': result, 'error': null}); } catch (e, stackTrace) { logger.w('webview rpc request failed: $method', e, stackTrace); - await ctx.writeJSON({ - 'result': null, - 'error': _toRpcError(method, e), - }); + await ctx.writeJSON({'result': null, 'error': _toRpcError(method, e)}); } } @@ -154,10 +141,7 @@ class WebViewRpcService { ); return {}; case 'page.execute': - return _page(params).execute( - _string(params, 'expression'), - (_list(params, 'args') ?? const []), - ); + return _page(params).execute(_string(params, 'expression'), (_list(params, 'args') ?? const [])); case 'page.getCookies': return _page(params).getCookies(); case 'page.setCookie': @@ -173,10 +157,7 @@ class WebViewRpcService { await _closePage(_pageId(params)); return {}; default: - throw WebViewRpcException( - code: 'UNKNOWN_METHOD', - message: 'unknown method: $method', - ); + throw WebViewRpcException(code: 'UNKNOWN_METHOD', message: 'unknown method: $method'); } } @@ -207,10 +188,7 @@ class WebViewRpcService { WebViewRpcPageSession _page(Map params) { final page = _pagesById[_pageId(params)]; if (page == null) { - throw WebViewRpcException( - code: 'PAGE_NOT_FOUND', - message: 'page not found', - ); + throw WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'page not found'); } return page; } @@ -222,10 +200,7 @@ class WebViewRpcService { if (value is String && value.isNotEmpty) { return value; } - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'missing or invalid "$key"', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'missing or invalid "$key"'); } String? _stringOrNull(Map params, String key) { @@ -236,10 +211,7 @@ class WebViewRpcService { if (value is String && value.isNotEmpty) { return value; } - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'invalid "$key"', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'invalid "$key"'); } int? _int(Map params, String key) { @@ -250,10 +222,7 @@ class WebViewRpcService { if (value is num) { return value.toInt(); } - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'invalid "$key"', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'invalid "$key"'); } List? _list(Map params, String key) { @@ -264,10 +233,7 @@ class WebViewRpcService { if (value is List) { return value; } - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'invalid "$key"', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'invalid "$key"'); } Map _cookie(Map params) { @@ -278,28 +244,20 @@ class WebViewRpcService { if (value is Map) { return value.cast(); } - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'missing or invalid "cookie"', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'missing or invalid "cookie"'); } Future _closePage(String pageId) async { final page = _pagesById.remove(pageId); if (page == null) { - throw WebViewRpcException( - code: 'PAGE_NOT_FOUND', - message: 'page not found', - ); + throw WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'page not found'); } _syncPages(); await page.dispose(); } void _syncPages() { - pages.value = _pagesById.values - .where((page) => !page.headless) - .toList(growable: false); + pages.value = _pagesById.values.where((page) => !page.headless).toList(growable: false); } WebViewRpcConfig _toConfig(RpcBinding binding) { @@ -311,20 +269,14 @@ class WebViewRpcService { Map _toRpcError(String method, Object error) { if (error is WebViewRpcException) { - return { - 'code': error.code, - 'message': error.message, - }; + return {'code': error.code, 'message': error.message}; } final code = switch (method) { 'page.goto' => 'NAVIGATION_FAILED', 'page.execute' => 'EVALUATION_FAILED', _ => 'INTERNAL_ERROR', }; - return { - 'code': code, - 'message': error.toString(), - }; + return {'code': code, 'message': error.toString()}; } } @@ -351,8 +303,7 @@ class WebViewRpcPageSession { late final String callbackChannelName; final List _initScripts = []; - final Map> _pendingExecutions = - >{}; + final Map> _pendingExecutions = >{}; final Completer _ready = Completer(); HeadlessInAppWebView? _headlessWebView; @@ -363,14 +314,10 @@ class WebViewRpcPageSession { bool _disposed = false; Future init() async { - callbackChannelName = - '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}'; + callbackChannelName = '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}'; if (headless) { _headlessWebView = HeadlessInAppWebView( - initialSize: Size( - width > 0 ? width.toDouble() : 1, - height > 0 ? height.toDouble() : 1, - ), + initialSize: Size(width > 0 ? width.toDouble() : 1, height > 0 ? height.toDouble() : 1), initialSettings: _settings, initialUserScripts: UnmodifiableListView(_initScripts), onWebViewCreated: _attachController, @@ -385,10 +332,7 @@ class WebViewRpcPageSession { } Future addInitScript(String script) async { - final userScript = UserScript( - source: script, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ); + final userScript = UserScript(source: script, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START); _initScripts.add(userScript); final controller = await _controllerOrThrow(); await controller.addUserScript(userScript: userScript); @@ -412,16 +356,10 @@ class WebViewRpcPageSession { if (identical(_navigation, navigation)) { _navigation = null; } - throw WebViewRpcException( - code: 'NAVIGATION_FAILED', - message: e.toString(), - ); + throw WebViewRpcException(code: 'NAVIGATION_FAILED', message: e.toString()); } final future = switch (waitStrategy) { - 'domcontentloaded' => Future.any([ - _waitForDomReady(), - navigation.future, - ]), + 'domcontentloaded' => Future.any([_waitForDomReady(), navigation.future]), _ => navigation.future, }; if (timeoutMs == null || timeoutMs <= 0) { @@ -432,10 +370,7 @@ class WebViewRpcPageSession { Duration(milliseconds: timeoutMs), onTimeout: () { _navigation = null; - throw WebViewRpcException( - code: 'TIMEOUT', - message: 'navigation timeout after ${timeoutMs}ms', - ); + throw WebViewRpcException(code: 'TIMEOUT', message: 'navigation timeout after ${timeoutMs}ms'); }, ); } @@ -457,10 +392,7 @@ class WebViewRpcPageSession { ); } catch (e) { _pendingExecutions.remove(requestId); - throw WebViewRpcException( - code: 'EVALUATION_FAILED', - message: e.toString(), - ); + throw WebViewRpcException(code: 'EVALUATION_FAILED', message: e.toString()); } try { @@ -468,10 +400,7 @@ class WebViewRpcPageSession { const Duration(seconds: 30), onTimeout: () { _pendingExecutions.remove(requestId); - throw WebViewRpcException( - code: 'TIMEOUT', - message: 'javascript execution timeout', - ); + throw WebViewRpcException(code: 'TIMEOUT', message: 'javascript execution timeout'); }, ); return result; @@ -490,19 +419,14 @@ class WebViewRpcPageSession { case 'domcontentloaded': return 'domcontentloaded'; default: - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'invalid "waitUntil"', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'invalid "waitUntil"'); } } Future _waitForDomReady() async { final controller = await _controllerOrThrow(); while (true) { - final readyState = await controller.evaluateJavascript( - source: 'document.readyState', - ); + final readyState = await controller.evaluateJavascript(source: 'document.readyState'); final state = readyState?.toString() ?? ''; if (state == 'interactive' || state == 'complete') { return; @@ -516,16 +440,18 @@ class WebViewRpcPageSession { return const []; } final cookies = await cookieManager.getCookies(url: WebUri(_currentUrl)); - return cookies.map((cookie) { - return { - 'name': cookie.name, - 'value': cookie.value, - 'domain': cookie.domain ?? '', - 'path': cookie.path ?? '/', - 'secure': cookie.isSecure, - 'httpOnly': cookie.isHttpOnly, - }; - }).toList(growable: false); + return cookies + .map((cookie) { + return { + 'name': cookie.name, + 'value': cookie.value, + 'domain': cookie.domain ?? '', + 'path': cookie.path ?? '/', + 'secure': cookie.isSecure, + 'httpOnly': cookie.isHttpOnly, + }; + }) + .toList(growable: false); } Future setCookie(Map cookie) async { @@ -547,10 +473,7 @@ class WebViewRpcPageSession { final path = cookie['path'] as String? ?? '/'; final domain = cookie['domain'] as String? ?? ''; if (name.isEmpty) { - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'cookie.name is required', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'cookie.name is required'); } await cookieManager.deleteCookie( url: _cookieUrl(cookie), @@ -567,29 +490,14 @@ class WebViewRpcPageSession { Future dispose() async { _disposed = true; if (!_ready.isCompleted) { - _ready.completeError( - WebViewRpcException( - code: 'PAGE_NOT_FOUND', - message: 'webview page closed', - ), - ); + _ready.completeError(WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'webview page closed')); } if (_navigation != null) { - _completeNavigationError( - WebViewRpcException( - code: 'NAVIGATION_FAILED', - message: 'webview page closed', - ), - ); + _completeNavigationError(WebViewRpcException(code: 'NAVIGATION_FAILED', message: 'webview page closed')); } for (final completer in _pendingExecutions.values) { if (!completer.isCompleted) { - completer.completeError( - WebViewRpcException( - code: 'EVALUATION_FAILED', - message: 'webview page closed', - ), - ); + completer.completeError(WebViewRpcException(code: 'EVALUATION_FAILED', message: 'webview page closed')); } } _pendingExecutions.clear(); @@ -616,11 +524,11 @@ class WebViewRpcPageSession { } InAppWebViewSettings get _settings => InAppWebViewSettings( - javaScriptEnabled: true, - transparentBackground: true, - isInspectable: debug, - userAgent: userAgent.isNotEmpty ? userAgent : null, - ); + javaScriptEnabled: true, + transparentBackground: true, + isInspectable: debug, + userAgent: userAgent.isNotEmpty ? userAgent : null, + ); Future _controllerOrThrow() async { await _ready.future; @@ -628,10 +536,7 @@ class WebViewRpcPageSession { if (controller != null && !_disposed) { return controller; } - throw WebViewRpcException( - code: 'PAGE_NOT_FOUND', - message: 'page not found', - ); + throw WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'page not found'); } void _attachController(InAppWebViewController controller) { @@ -655,25 +560,13 @@ class WebViewRpcPageSession { _ensureNavigationCompleter(); } - Future _handleLoadStop( - InAppWebViewController controller, - WebUri? url, - ) async { + Future _handleLoadStop(InAppWebViewController controller, WebUri? url) async { _currentUrl = url?.toString() ?? _currentUrl; _completeNavigation(); } - void _handleReceivedError( - InAppWebViewController controller, - WebResourceRequest request, - WebResourceError error, - ) { - _completeNavigationError( - WebViewRpcException( - code: 'NAVIGATION_FAILED', - message: error.description, - ), - ); + void _handleReceivedError(InAppWebViewController controller, WebResourceRequest request, WebResourceError error) { + _completeNavigationError(WebViewRpcException(code: 'NAVIGATION_FAILED', message: error.description)); } void _handleReceivedHttpError( @@ -684,8 +577,7 @@ class WebViewRpcPageSession { _completeNavigationError( WebViewRpcException( code: 'NAVIGATION_FAILED', - message: - 'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}', + message: 'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}', ), ); } @@ -715,10 +607,7 @@ class WebViewRpcPageSession { if (_currentUrl.isNotEmpty) { return WebUri(_currentUrl); } - throw WebViewRpcException( - code: 'INVALID_REQUEST', - message: 'cookie target url is unavailable', - ); + throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'cookie target url is unavailable'); } int? _cookieExpires(Map cookie) { @@ -766,10 +655,7 @@ class WebViewRpcException implements Exception { final String code; final String message; - WebViewRpcException({ - required this.code, - required this.message, - }); + WebViewRpcException({required this.code, required this.message}); @override String toString() => message; diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart new file mode 100644 index 00000000..ba736b39 --- /dev/null +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -0,0 +1,170 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/api.dart' as api; +import '../../api/model/create_task.dart'; +import '../../api/model/create_task_batch.dart'; +import '../../api/model/downloader_config.dart'; +import '../../api/model/extension.dart'; +import '../../api/model/install_extension.dart'; +import '../../api/model/resolve_result.dart'; +import '../../api/model/resolve_task.dart'; +import '../../api/model/switch_extension.dart'; +import '../../api/model/task.dart'; +import '../../api/model/update_check_extension_resp.dart'; +import '../../api/model/update_extension_settings.dart'; +import '../../database/database.dart'; +import '../window/app_window_appearance.dart'; +import 'capability_rpc.dart'; +import 'gopeed_capability.dart'; +import 'storage_capability.dart'; + +class AppCapabilities { + AppCapabilities(CapabilityInvoker invoker) : gopeed = GopeedService(invoker), storage = AppStorageService(invoker); + + final GopeedService gopeed; + final AppStorageService storage; +} + +class LocalAppCapabilities { + LocalAppCapabilities._() : codecs = createAppCapabilityCodecs() { + registry = CapabilityRegistry(codecs); + _bindGopeed(registry); + _bindStorage(registry); + capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + } + + static final instance = LocalAppCapabilities._(); + + final RpcCodecRegistry codecs; + late CapabilityRegistry registry; + late AppCapabilities capabilities; +} + +final appCapabilitiesProvider = Provider((ref) => LocalAppCapabilities.instance.capabilities); +final gopeedServiceProvider = Provider((ref) => ref.watch(appCapabilitiesProvider).gopeed); +final appStorageServiceProvider = Provider((ref) => ref.watch(appCapabilitiesProvider).storage); + +RpcCodecRegistry createAppCapabilityCodecs() { + final codecs = RpcCodecRegistry(); + codecs + ..register((json) => AppWindowAppearance.fromJson(json! as Map)) + ..register((json) => ResolveTask.fromJson(json! as Map)) + ..register((json) => ResolveResult.fromJson(json! as Map)) + ..register((json) => CreateTask.fromJson(json! as Map)) + ..register((json) => CreateTaskBatch.fromJson(json! as Map)) + ..register((json) => DownloaderConfig.fromJson(json! as Map)) + ..register((json) => TaskRuntimeStatus.fromJson(json! as Map)) + ..register>( + (json) => + (json! as List).map((item) => Task.fromJson(item as Map)).toList(growable: false), + ) + ..register((json) => InstallExtension.fromJson(json! as Map)) + ..register((json) => UpdateExtensionSettings.fromJson(json! as Map)) + ..register((json) => SwitchExtension.fromJson(json! as Map)) + ..register>( + (json) => (json! as List) + .map((item) => Extension.fromJson(item as Map)) + .toList(growable: false), + ) + ..register((json) => UpdateCheckExtensionResp.fromJson(json! as Map)); + return codecs; +} + +void _bindGopeed(CapabilityRegistry registry) { + registry + ..bind(GopeedMethods.resolve, api.resolve) + ..bind(GopeedMethods.createTask, api.createTask) + ..bind(GopeedMethods.createTaskBatch, api.createTaskBatch) + ..bind(GopeedMethods.patchTask, (params) async { + await api.patchTask(params['id'] as String, ResolveTask.fromJson(params['request'] as Map)); + return const RpcUnit(); + }) + ..bind( + GopeedMethods.getTasks, + (statuses) => api.getTasks(statuses.map((name) => Status.values.byName(name)).toList(growable: false)), + ) + ..bind(GopeedMethods.getTaskStatus, api.getTaskStatus) + ..bind(GopeedMethods.getTaskStats, api.getTaskStats) + ..bind(GopeedMethods.pauseTask, (id) async { + await api.pauseTask(id); + return const RpcUnit(); + }) + ..bind(GopeedMethods.continueTask, (id) async { + await api.continueTask(id); + return const RpcUnit(); + }) + ..bind(GopeedMethods.pauseTasks, (params) async { + await api.pauseAllTasks(_optionalStringList(params['ids'])); + return const RpcUnit(); + }) + ..bind(GopeedMethods.continueTasks, (params) async { + await api.continueAllTasks(_optionalStringList(params['ids'])); + return const RpcUnit(); + }) + ..bind(GopeedMethods.deleteTask, (params) async { + await api.deleteTask(params['id'] as String, params['force'] as bool? ?? false); + return const RpcUnit(); + }) + ..bind(GopeedMethods.deleteTasks, (params) async { + await api.deleteTasks(_optionalStringList(params['ids']), params['force'] as bool? ?? false); + return const RpcUnit(); + }) + ..bind(GopeedMethods.getConfig, (_) => api.getConfig()) + ..bind(GopeedMethods.putConfig, (config) async { + await api.putConfig(config); + return const RpcUnit(); + }) + ..bind(GopeedMethods.installExtension, api.installExtension) + ..bind(GopeedMethods.getExtensions, (_) => api.getExtensions()) + ..bind(GopeedMethods.updateExtensionSettings, (params) async { + await api.updateExtensionSettings( + params['identity'] as String, + UpdateExtensionSettings.fromJson(params['request'] as Map), + ); + return const RpcUnit(); + }) + ..bind(GopeedMethods.switchExtension, (params) async { + await api.switchExtension( + params['identity'] as String, + SwitchExtension.fromJson(params['request'] as Map), + ); + return const RpcUnit(); + }) + ..bind(GopeedMethods.deleteExtension, (identity) async { + await api.deleteExtension(identity); + return const RpcUnit(); + }) + ..bind(GopeedMethods.checkExtensionUpdate, api.upgradeCheckExtension) + ..bind(GopeedMethods.updateExtension, (identity) async { + await api.updateExtension(identity); + return const RpcUnit(); + }) + ..bind(GopeedMethods.testWebhook, (url) async { + await api.testWebhook(url); + return const RpcUnit(); + }); +} + +void _bindStorage(CapabilityRegistry registry) { + registry + ..bind(StorageMethods.getCreateHistory, (_) async => Database.instance.getCreateHistory() ?? const []) + ..bind(StorageMethods.saveCreateHistory, (urls) async { + for (final url in urls) { + Database.instance.saveCreateHistory(url); + } + return const RpcUnit(); + }) + ..bind(StorageMethods.removeCreateHistory, (url) async { + Database.instance.removeCreateHistory(url); + return const RpcUnit(); + }) + ..bind(StorageMethods.clearCreateHistory, (_) async { + Database.instance.clearCreateHistory(); + return const RpcUnit(); + }); +} + +List? _optionalStringList(Object? value) { + if (value == null) return null; + return (value as List).map((item) => item.toString()).toList(growable: false); +} diff --git a/ui/flutter/lib/core/capabilities/capability_rpc.dart b/ui/flutter/lib/core/capabilities/capability_rpc.dart new file mode 100644 index 00000000..dc10ae4d --- /dev/null +++ b/ui/flutter/lib/core/capabilities/capability_rpc.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +typedef RpcDecoder = T Function(Object? json); + +class RpcUnit { + const RpcUnit(); +} + +class RpcMethod { + const RpcMethod(this.name); + + final String name; +} + +class CapabilityException implements Exception { + const CapabilityException(this.code, this.message, [this.details]); + + final String code; + final String message; + final Object? details; + + @override + String toString() => message; +} + +class RpcCodecRegistry { + final Map> _decoders = {}; + + void register(RpcDecoder decoder) { + _decoders[T] = (json) => decoder(json); + } + + Object? encode(Object? value) { + if (value == null || value is String || value is num || value is bool) { + return value; + } + if (value is RpcUnit) { + return const {}; + } + if (value is Enum) { + return value.name; + } + if (value is Iterable) { + return value.map(encode).toList(growable: false); + } + if (value is Map) { + return {for (final entry in value.entries) entry.key.toString(): encode(entry.value)}; + } + try { + return encode((value as dynamic).toJson()); + } catch (_) { + throw CapabilityException('encode_error', 'No RPC encoder registered for ${value.runtimeType}'); + } + } + + T decode(Object? value) { + final normalized = _normalize(value); + if (T == RpcUnit) { + return const RpcUnit() as T; + } + if (normalized == null) { + return null as T; + } + if (T == List) { + return (normalized as List).map((item) => item.toString()).toList(growable: false) as T; + } + if (normalized is T) { + return normalized as T; + } + final decoder = _decoders[T]; + if (decoder == null) { + throw CapabilityException('decode_error', 'No RPC decoder registered for $T'); + } + return decoder(normalized) as T; + } + + Object? _normalize(Object? value) { + if (value is Map) { + return {for (final entry in value.entries) entry.key.toString(): _normalize(entry.value)}; + } + if (value is List) { + return value.map(_normalize).toList(growable: false); + } + return value; + } +} + +abstract interface class CapabilityInvoker { + Future invoke(RpcMethod method, P params); +} + +typedef _RawCapabilityHandler = Future Function(Object? payload); + +class CapabilityRegistry { + CapabilityRegistry(this.codecs); + + final RpcCodecRegistry codecs; + final Map _handlers = {}; + final Map _localHandlers = {}; + + void bind(RpcMethod method, FutureOr Function(P params) handler) { + if (_handlers.containsKey(method.name)) { + throw StateError('Capability already registered: ${method.name}'); + } + _localHandlers[method.name] = handler; + _handlers[method.name] = (payload) async { + final params = codecs.decode

(payload); + return codecs.encode(await handler(params)); + }; + } + + Future invoke(String operation, Object? payload) async { + final handler = _handlers[operation]; + if (handler == null) { + throw CapabilityException('method_not_found', 'Capability not found: $operation'); + } + return handler(payload); + } + + Future invokeLocal(RpcMethod method, P params) async { + final handler = _localHandlers[method.name]; + if (handler == null) { + throw CapabilityException('method_not_found', 'Capability not found: ${method.name}'); + } + return await (handler as FutureOr Function(P))(params); + } +} + +class LocalCapabilityInvoker implements CapabilityInvoker { + const LocalCapabilityInvoker(this.registry); + + final CapabilityRegistry registry; + + @override + Future invoke(RpcMethod method, P params) async { + return registry.invokeLocal(method, params); + } +} diff --git a/ui/flutter/lib/core/capabilities/gopeed_capability.dart b/ui/flutter/lib/core/capabilities/gopeed_capability.dart new file mode 100644 index 00000000..ea03dd68 --- /dev/null +++ b/ui/flutter/lib/core/capabilities/gopeed_capability.dart @@ -0,0 +1,107 @@ +import '../../api/model/create_task.dart'; +import '../../api/model/create_task_batch.dart'; +import '../../api/model/downloader_config.dart'; +import '../../api/model/extension.dart'; +import '../../api/model/install_extension.dart'; +import '../../api/model/resolve_result.dart'; +import '../../api/model/resolve_task.dart'; +import '../../api/model/switch_extension.dart'; +import '../../api/model/task.dart'; +import '../../api/model/update_check_extension_resp.dart'; +import '../../api/model/update_extension_settings.dart'; +import 'capability_rpc.dart'; + +abstract final class GopeedMethods { + static const resolve = RpcMethod('gopeed.resolve'); + static const createTask = RpcMethod('gopeed.task.create'); + static const createTaskBatch = RpcMethod>('gopeed.task.createBatch'); + static const patchTask = RpcMethod, RpcUnit>('gopeed.task.patch'); + static const getTasks = RpcMethod, List>('gopeed.task.list'); + static const getTaskStatus = RpcMethod('gopeed.task.status'); + static const getTaskStats = RpcMethod>('gopeed.task.stats'); + static const pauseTask = RpcMethod('gopeed.task.pause'); + static const continueTask = RpcMethod('gopeed.task.continue'); + static const pauseTasks = RpcMethod, RpcUnit>('gopeed.task.pauseBatch'); + static const continueTasks = RpcMethod, RpcUnit>('gopeed.task.continueBatch'); + static const deleteTask = RpcMethod, RpcUnit>('gopeed.task.delete'); + static const deleteTasks = RpcMethod, RpcUnit>('gopeed.task.deleteBatch'); + static const getConfig = RpcMethod('gopeed.config.get'); + static const putConfig = RpcMethod('gopeed.config.put'); + static const installExtension = RpcMethod('gopeed.extension.install'); + static const getExtensions = RpcMethod>('gopeed.extension.list'); + static const updateExtensionSettings = RpcMethod, RpcUnit>('gopeed.extension.updateSettings'); + static const switchExtension = RpcMethod, RpcUnit>('gopeed.extension.switch'); + static const deleteExtension = RpcMethod('gopeed.extension.delete'); + static const checkExtensionUpdate = RpcMethod('gopeed.extension.checkUpdate'); + static const updateExtension = RpcMethod('gopeed.extension.update'); + static const testWebhook = RpcMethod('gopeed.webhook.test'); +} + +class GopeedService { + const GopeedService(this._invoker); + + final CapabilityInvoker _invoker; + + Future resolve(ResolveTask request) => _invoker.invoke(GopeedMethods.resolve, request); + + Future createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request); + + Future> createTaskBatch(CreateTaskBatch request) => + _invoker.invoke(GopeedMethods.createTaskBatch, request); + + Future patchTask(String id, ResolveTask request) async { + await _invoker.invoke(GopeedMethods.patchTask, {'id': id, 'request': request.toJson()}); + } + + Future> getTasks(List statuses) => + _invoker.invoke(GopeedMethods.getTasks, statuses.map((status) => status.name).toList(growable: false)); + + Future getTaskStatus(String id) => _invoker.invoke(GopeedMethods.getTaskStatus, id); + + Future> getTaskStats(String id) => _invoker.invoke(GopeedMethods.getTaskStats, id); + + Future pauseTask(String id) async => _invoker.invoke(GopeedMethods.pauseTask, id); + + Future continueTask(String id) async => _invoker.invoke(GopeedMethods.continueTask, id); + + Future pauseAllTasks(List? ids) async { + await _invoker.invoke(GopeedMethods.pauseTasks, {'ids': ids}); + } + + Future continueAllTasks(List? ids) async { + await _invoker.invoke(GopeedMethods.continueTasks, {'ids': ids}); + } + + Future deleteTask(String id, bool force) async { + await _invoker.invoke(GopeedMethods.deleteTask, {'id': id, 'force': force}); + } + + Future deleteTasks(List? ids, bool force) async { + await _invoker.invoke(GopeedMethods.deleteTasks, {'ids': ids, 'force': force}); + } + + Future getConfig() => _invoker.invoke(GopeedMethods.getConfig, const RpcUnit()); + + Future putConfig(DownloaderConfig config) async => _invoker.invoke(GopeedMethods.putConfig, config); + + Future installExtension(InstallExtension request) => _invoker.invoke(GopeedMethods.installExtension, request); + + Future> getExtensions() => _invoker.invoke(GopeedMethods.getExtensions, const RpcUnit()); + + Future updateExtensionSettings(String identity, UpdateExtensionSettings request) async { + await _invoker.invoke(GopeedMethods.updateExtensionSettings, {'identity': identity, 'request': request.toJson()}); + } + + Future switchExtension(String identity, SwitchExtension request) async { + await _invoker.invoke(GopeedMethods.switchExtension, {'identity': identity, 'request': request.toJson()}); + } + + Future deleteExtension(String identity) async => _invoker.invoke(GopeedMethods.deleteExtension, identity); + + Future upgradeCheckExtension(String identity) => + _invoker.invoke(GopeedMethods.checkExtensionUpdate, identity); + + Future updateExtension(String identity) async => _invoker.invoke(GopeedMethods.updateExtension, identity); + + Future testWebhook(String url) async => _invoker.invoke(GopeedMethods.testWebhook, url); +} diff --git a/ui/flutter/lib/core/capabilities/storage_capability.dart b/ui/flutter/lib/core/capabilities/storage_capability.dart new file mode 100644 index 00000000..f4e65b08 --- /dev/null +++ b/ui/flutter/lib/core/capabilities/storage_capability.dart @@ -0,0 +1,29 @@ +import 'capability_rpc.dart'; + +abstract final class StorageMethods { + static const getCreateHistory = RpcMethod>('storage.createHistory.get'); + static const saveCreateHistory = RpcMethod, RpcUnit>('storage.createHistory.save'); + static const removeCreateHistory = RpcMethod('storage.createHistory.remove'); + static const clearCreateHistory = RpcMethod('storage.createHistory.clear'); +} + +class AppStorageService { + const AppStorageService(this._invoker); + + final CapabilityInvoker _invoker; + + Future> getCreateHistory() => _invoker.invoke(StorageMethods.getCreateHistory, const RpcUnit()); + + Future saveCreateHistory(List urls) async { + if (urls.isEmpty) return; + await _invoker.invoke(StorageMethods.saveCreateHistory, urls); + } + + Future removeCreateHistory(String url) async { + await _invoker.invoke(StorageMethods.removeCreateHistory, url); + } + + Future clearCreateHistory() async { + await _invoker.invoke(StorageMethods.clearCreateHistory, const RpcUnit()); + } +} diff --git a/ui/flutter/lib/core/common/libgopeed_channel.dart b/ui/flutter/lib/core/common/libgopeed_channel.dart index a94d3744..31c1ee1a 100644 --- a/ui/flutter/lib/core/common/libgopeed_channel.dart +++ b/ui/flutter/lib/core/common/libgopeed_channel.dart @@ -10,9 +10,7 @@ class LibgopeedChannel implements LibgopeedInterface { @override Future start(StartConfig cfg) async { - final port = await _channel.invokeMethod('start', { - 'cfg': jsonEncode(cfg), - }); + final port = await _channel.invokeMethod('start', {'cfg': jsonEncode(cfg)}); return port as int; } diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/core/common/start_config.dart index f27d7477..f832e811 100644 --- a/ui/flutter/lib/core/common/start_config.dart +++ b/ui/flutter/lib/core/common/start_config.dart @@ -10,8 +10,7 @@ class WebViewRpcConfig { WebViewRpcConfig(); - factory WebViewRpcConfig.fromJson(Map json) => - _$WebViewRpcConfigFromJson(json); + factory WebViewRpcConfig.fromJson(Map json) => _$WebViewRpcConfigFromJson(json); Map toJson() => _$WebViewRpcConfigToJson(this); } @@ -28,8 +27,7 @@ class StartConfig { StartConfig(); - factory StartConfig.fromJson(Map json) => - _$StartConfigFromJson(json); + factory StartConfig.fromJson(Map json) => _$StartConfigFromJson(json); Map toJson() => _$StartConfigToJson(this); } diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/core/common/start_config.g.dart index d0202434..305faf21 100644 --- a/ui/flutter/lib/core/common/start_config.g.dart +++ b/ui/flutter/lib/core/common/start_config.g.dart @@ -16,7 +16,7 @@ Map _$WebViewRpcConfigToJson(WebViewRpcConfig instance) => { 'network': instance.network, 'address': instance.address, - 'token': instance.token, + 'token': ?instance.token, }; StartConfig _$StartConfigFromJson(Map json) => StartConfig() @@ -29,7 +29,8 @@ StartConfig _$StartConfigFromJson(Map json) => StartConfig() ..webViewRpcConfig = json['webViewRpcConfig'] == null ? null : WebViewRpcConfig.fromJson( - json['webViewRpcConfig'] as Map); + json['webViewRpcConfig'] as Map, + ); Map _$StartConfigToJson(StartConfig instance) => { @@ -39,5 +40,5 @@ Map _$StartConfigToJson(StartConfig instance) => 'storageDir': instance.storageDir, 'refreshInterval': instance.refreshInterval, 'apiToken': instance.apiToken, - 'webViewRpcConfig': instance.webViewRpcConfig, + 'webViewRpcConfig': ?instance.webViewRpcConfig, }; diff --git a/ui/flutter/lib/core/entry/app_initializer.dart b/ui/flutter/lib/core/entry/app_initializer.dart new file mode 100644 index 00000000..a7ab8d2c --- /dev/null +++ b/ui/flutter/lib/core/entry/app_initializer.dart @@ -0,0 +1,20 @@ +import '../../database/database.dart'; +import '../../util/util.dart'; + +class AppInitializer { + static Future? _storageInitialization; + static Future? _databaseInitialization; + + static Future ensureStorageInitialized() { + return _storageInitialization ??= Util.initStorageDir(); + } + + static Future ensureDatabaseInitialized() { + return _databaseInitialization ??= _initializeDatabase(); + } + + static Future _initializeDatabase() async { + await ensureStorageInitialized(); + await Database.instance.init(); + } +} diff --git a/ui/flutter/lib/core/entry/app_startup_options.dart b/ui/flutter/lib/core/entry/app_startup_options.dart new file mode 100644 index 00000000..f0c32164 --- /dev/null +++ b/ui/flutter/lib/core/entry/app_startup_options.dart @@ -0,0 +1,22 @@ +class AppStartupOptions { + const AppStartupOptions({required this.hidden}); + + factory AppStartupOptions.fromArgs(List args) { + return AppStartupOptions( + hidden: args.contains('--hidden') || args.map(Uri.tryParse).whereType().any(isSilentGopeedWakeUri), + ); + } + + final bool hidden; + + AppStartupOptions withInitialUri(Uri? uri) { + return AppStartupOptions(hidden: hidden || (uri != null && isSilentGopeedWakeUri(uri))); + } +} + +bool isSilentGopeedWakeUri(Uri uri) { + if (uri.scheme.toLowerCase() != 'gopeed' || uri.queryParameters['hidden'] != 'true') { + return false; + } + return uri.path.isEmpty || uri.path == '/'; +} diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart index 05efe963..6ea61cd0 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart @@ -9,9 +9,7 @@ LibgopeedBoot create() => LibgopeedBootBrowser(); class LibgopeedBootBrowser implements LibgopeedBoot { // do nothing @override - Future start(StartConfig cfg) async { - return 0; - } + Future start(StartConfig cfg) async => 0; @override Future stop() async {} diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart index c03f004c..6c81679b 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart @@ -38,8 +38,7 @@ class LibgopeedBootNative implements LibgopeedBoot { cfg.storage = 'bolt'; cfg.storageDir = Util.getStorageDir(); cfg.refreshInterval = 0; - var port = await _libgopeed.start(cfg); - return port; + return _libgopeed.start(cfg); } @override diff --git a/ui/flutter/lib/core/error/app_exception.dart b/ui/flutter/lib/core/error/app_exception.dart new file mode 100644 index 00000000..792f4f51 --- /dev/null +++ b/ui/flutter/lib/core/error/app_exception.dart @@ -0,0 +1,8 @@ +class AppException implements Exception { + const AppException(this.message); + + final String message; + + @override + String toString() => 'AppException: $message'; +} diff --git a/ui/flutter/lib/core/ffi/libgopeed_bind.dart b/ui/flutter/lib/core/ffi/libgopeed_bind.dart index 53ddedb7..8fd4ffc8 100644 --- a/ui/flutter/lib/core/ffi/libgopeed_bind.dart +++ b/ui/flutter/lib/core/ffi/libgopeed_bind.dart @@ -7,1028 +7,33 @@ import 'dart:ffi' as ffi; /// Bindings to gopeed library. class LibgopeedBind { /// Holds the symbol lookup function. - final ffi.Pointer Function(String symbolName) - _lookup; + final ffi.Pointer Function(String symbolName) _lookup; /// The symbols are looked up in [dynamicLibrary]. - LibgopeedBind(ffi.DynamicLibrary dynamicLibrary) - : _lookup = dynamicLibrary.lookup; + LibgopeedBind(ffi.DynamicLibrary dynamicLibrary) : _lookup = dynamicLibrary.lookup; /// The symbols are looked up with [lookup]. - LibgopeedBind.fromLookup( - ffi.Pointer Function(String symbolName) - lookup) - : _lookup = lookup; + LibgopeedBind.fromLookup(ffi.Pointer Function(String symbolName) lookup) + : _lookup = lookup; - void __va_start( - ffi.Pointer arg0, - ) { - return ___va_start( - arg0, - ); + Start_return Start(ffi.Pointer cfg) { + return _Start(cfg); } - late final ___va_startPtr = - _lookup)>>( - '__va_start'); - late final ___va_start = - ___va_startPtr.asFunction)>(); - - void __security_init_cookie() { - return ___security_init_cookie(); - } - - late final ___security_init_cookiePtr = - _lookup>( - '__security_init_cookie'); - late final ___security_init_cookie = - ___security_init_cookiePtr.asFunction(); - - void __security_check_cookie( - int _StackCookie, - ) { - return ___security_check_cookie( - _StackCookie, - ); - } - - late final ___security_check_cookiePtr = - _lookup>( - '__security_check_cookie'); - late final ___security_check_cookie = - ___security_check_cookiePtr.asFunction(); - - void __report_gsfailure( - int _StackCookie, - ) { - return ___report_gsfailure( - _StackCookie, - ); - } - - late final ___report_gsfailurePtr = - _lookup>( - '__report_gsfailure'); - late final ___report_gsfailure = - ___report_gsfailurePtr.asFunction(); - - late final ffi.Pointer ___security_cookie = - _lookup('__security_cookie'); - - int get __security_cookie => ___security_cookie.value; - - set __security_cookie(int value) => ___security_cookie.value = value; - - void _invalid_parameter_noinfo() { - return __invalid_parameter_noinfo(); - } - - late final __invalid_parameter_noinfoPtr = - _lookup>( - '_invalid_parameter_noinfo'); - late final __invalid_parameter_noinfo = - __invalid_parameter_noinfoPtr.asFunction(); - - void _invalid_parameter_noinfo_noreturn() { - return __invalid_parameter_noinfo_noreturn(); - } - - late final __invalid_parameter_noinfo_noreturnPtr = - _lookup>( - '_invalid_parameter_noinfo_noreturn'); - late final __invalid_parameter_noinfo_noreturn = - __invalid_parameter_noinfo_noreturnPtr.asFunction(); - - void _invoke_watson( - ffi.Pointer _Expression, - ffi.Pointer _FunctionName, - ffi.Pointer _FileName, - int _LineNo, - int _Reserved, - ) { - return __invoke_watson( - _Expression, - _FunctionName, - _FileName, - _LineNo, - _Reserved, - ); - } - - late final __invoke_watsonPtr = _lookup< - ffi.NativeFunction< - ffi.Void Function( - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ffi.UnsignedInt, - ffi.UintPtr)>>('_invoke_watson'); - late final __invoke_watson = __invoke_watsonPtr.asFunction< - void Function(ffi.Pointer, ffi.Pointer, - ffi.Pointer, int, int)>(); - - ffi.Pointer _errno() { - return __errno(); - } - - late final __errnoPtr = - _lookup Function()>>('_errno'); - late final __errno = __errnoPtr.asFunction Function()>(); - - int _set_errno( - int _Value, - ) { - return __set_errno( - _Value, - ); - } - - late final __set_errnoPtr = - _lookup>('_set_errno'); - late final __set_errno = __set_errnoPtr.asFunction(); - - int _get_errno( - ffi.Pointer _Value, - ) { - return __get_errno( - _Value, - ); - } - - late final __get_errnoPtr = - _lookup)>>( - '_get_errno'); - late final __get_errno = - __get_errnoPtr.asFunction)>(); - - int __threadid() { - return ___threadid(); - } - - late final ___threadidPtr = - _lookup>('__threadid'); - late final ___threadid = ___threadidPtr.asFunction(); - - int __threadhandle() { - return ___threadhandle(); - } - - late final ___threadhandlePtr = - _lookup>('__threadhandle'); - late final ___threadhandle = ___threadhandlePtr.asFunction(); - - double cabs( - _Dcomplex _Z, - ) { - return _cabs( - _Z, - ); - } - - late final _cabsPtr = - _lookup>('cabs'); - late final _cabs = _cabsPtr.asFunction(); - - _Dcomplex cacos( - _Dcomplex _Z, - ) { - return _cacos( - _Z, - ); - } - - late final _cacosPtr = - _lookup>('cacos'); - late final _cacos = _cacosPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex cacosh( - _Dcomplex _Z, - ) { - return _cacosh( - _Z, - ); - } - - late final _cacoshPtr = - _lookup>('cacosh'); - late final _cacosh = _cacoshPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - double carg( - _Dcomplex _Z, - ) { - return _carg( - _Z, - ); - } - - late final _cargPtr = - _lookup>('carg'); - late final _carg = _cargPtr.asFunction(); - - _Dcomplex casin( - _Dcomplex _Z, - ) { - return _casin( - _Z, - ); - } - - late final _casinPtr = - _lookup>('casin'); - late final _casin = _casinPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex casinh( - _Dcomplex _Z, - ) { - return _casinh( - _Z, - ); - } - - late final _casinhPtr = - _lookup>('casinh'); - late final _casinh = _casinhPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex catan( - _Dcomplex _Z, - ) { - return _catan( - _Z, - ); - } - - late final _catanPtr = - _lookup>('catan'); - late final _catan = _catanPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex catanh( - _Dcomplex _Z, - ) { - return _catanh( - _Z, - ); - } - - late final _catanhPtr = - _lookup>('catanh'); - late final _catanh = _catanhPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex ccos( - _Dcomplex _Z, - ) { - return _ccos( - _Z, - ); - } - - late final _ccosPtr = - _lookup>('ccos'); - late final _ccos = _ccosPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex ccosh( - _Dcomplex _Z, - ) { - return _ccosh( - _Z, - ); - } - - late final _ccoshPtr = - _lookup>('ccosh'); - late final _ccosh = _ccoshPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex cexp( - _Dcomplex _Z, - ) { - return _cexp( - _Z, - ); - } - - late final _cexpPtr = - _lookup>('cexp'); - late final _cexp = _cexpPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - double cimag( - _Dcomplex _Z, - ) { - return _cimag( - _Z, - ); - } - - late final _cimagPtr = - _lookup>('cimag'); - late final _cimag = _cimagPtr.asFunction(); - - _Dcomplex clog( - _Dcomplex _Z, - ) { - return _clog( - _Z, - ); - } - - late final _clogPtr = - _lookup>('clog'); - late final _clog = _clogPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex clog10( - _Dcomplex _Z, - ) { - return _clog10( - _Z, - ); - } - - late final _clog10Ptr = - _lookup>('clog10'); - late final _clog10 = _clog10Ptr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex conj( - _Dcomplex _Z, - ) { - return _conj( - _Z, - ); - } - - late final _conjPtr = - _lookup>('conj'); - late final _conj = _conjPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex cpow( - _Dcomplex _X, - _Dcomplex _Y, - ) { - return _cpow( - _X, - _Y, - ); - } - - late final _cpowPtr = - _lookup>( - 'cpow'); - late final _cpow = - _cpowPtr.asFunction<_Dcomplex Function(_Dcomplex, _Dcomplex)>(); - - _Dcomplex cproj( - _Dcomplex _Z, - ) { - return _cproj( - _Z, - ); - } - - late final _cprojPtr = - _lookup>('cproj'); - late final _cproj = _cprojPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - double creal( - _Dcomplex _Z, - ) { - return _creal( - _Z, - ); - } - - late final _crealPtr = - _lookup>('creal'); - late final _creal = _crealPtr.asFunction(); - - _Dcomplex csin( - _Dcomplex _Z, - ) { - return _csin( - _Z, - ); - } - - late final _csinPtr = - _lookup>('csin'); - late final _csin = _csinPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex csinh( - _Dcomplex _Z, - ) { - return _csinh( - _Z, - ); - } - - late final _csinhPtr = - _lookup>('csinh'); - late final _csinh = _csinhPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex csqrt( - _Dcomplex _Z, - ) { - return _csqrt( - _Z, - ); - } - - late final _csqrtPtr = - _lookup>('csqrt'); - late final _csqrt = _csqrtPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex ctan( - _Dcomplex _Z, - ) { - return _ctan( - _Z, - ); - } - - late final _ctanPtr = - _lookup>('ctan'); - late final _ctan = _ctanPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - _Dcomplex ctanh( - _Dcomplex _Z, - ) { - return _ctanh( - _Z, - ); - } - - late final _ctanhPtr = - _lookup>('ctanh'); - late final _ctanh = _ctanhPtr.asFunction<_Dcomplex Function(_Dcomplex)>(); - - double norm( - _Dcomplex _Z, - ) { - return _norm( - _Z, - ); - } - - late final _normPtr = - _lookup>('norm'); - late final _norm = _normPtr.asFunction(); - - double cabsf( - _Fcomplex _Z, - ) { - return _cabsf( - _Z, - ); - } - - late final _cabsfPtr = - _lookup>('cabsf'); - late final _cabsf = _cabsfPtr.asFunction(); - - _Fcomplex cacosf( - _Fcomplex _Z, - ) { - return _cacosf( - _Z, - ); - } - - late final _cacosfPtr = - _lookup>('cacosf'); - late final _cacosf = _cacosfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex cacoshf( - _Fcomplex _Z, - ) { - return _cacoshf( - _Z, - ); - } - - late final _cacoshfPtr = - _lookup>('cacoshf'); - late final _cacoshf = _cacoshfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - double cargf( - _Fcomplex _Z, - ) { - return _cargf( - _Z, - ); - } - - late final _cargfPtr = - _lookup>('cargf'); - late final _cargf = _cargfPtr.asFunction(); - - _Fcomplex casinf( - _Fcomplex _Z, - ) { - return _casinf( - _Z, - ); - } - - late final _casinfPtr = - _lookup>('casinf'); - late final _casinf = _casinfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex casinhf( - _Fcomplex _Z, - ) { - return _casinhf( - _Z, - ); - } - - late final _casinhfPtr = - _lookup>('casinhf'); - late final _casinhf = _casinhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex catanf( - _Fcomplex _Z, - ) { - return _catanf( - _Z, - ); - } - - late final _catanfPtr = - _lookup>('catanf'); - late final _catanf = _catanfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex catanhf( - _Fcomplex _Z, - ) { - return _catanhf( - _Z, - ); - } - - late final _catanhfPtr = - _lookup>('catanhf'); - late final _catanhf = _catanhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex ccosf( - _Fcomplex _Z, - ) { - return _ccosf( - _Z, - ); - } - - late final _ccosfPtr = - _lookup>('ccosf'); - late final _ccosf = _ccosfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex ccoshf( - _Fcomplex _Z, - ) { - return _ccoshf( - _Z, - ); - } - - late final _ccoshfPtr = - _lookup>('ccoshf'); - late final _ccoshf = _ccoshfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex cexpf( - _Fcomplex _Z, - ) { - return _cexpf( - _Z, - ); - } - - late final _cexpfPtr = - _lookup>('cexpf'); - late final _cexpf = _cexpfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - double cimagf( - _Fcomplex _Z, - ) { - return _cimagf( - _Z, - ); - } - - late final _cimagfPtr = - _lookup>('cimagf'); - late final _cimagf = _cimagfPtr.asFunction(); - - _Fcomplex clogf( - _Fcomplex _Z, - ) { - return _clogf( - _Z, - ); - } - - late final _clogfPtr = - _lookup>('clogf'); - late final _clogf = _clogfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex clog10f( - _Fcomplex _Z, - ) { - return _clog10f( - _Z, - ); - } - - late final _clog10fPtr = - _lookup>('clog10f'); - late final _clog10f = _clog10fPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex conjf( - _Fcomplex _Z, - ) { - return _conjf( - _Z, - ); - } - - late final _conjfPtr = - _lookup>('conjf'); - late final _conjf = _conjfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex cpowf( - _Fcomplex _X, - _Fcomplex _Y, - ) { - return _cpowf( - _X, - _Y, - ); - } - - late final _cpowfPtr = - _lookup>( - 'cpowf'); - late final _cpowf = - _cpowfPtr.asFunction<_Fcomplex Function(_Fcomplex, _Fcomplex)>(); - - _Fcomplex cprojf( - _Fcomplex _Z, - ) { - return _cprojf( - _Z, - ); - } - - late final _cprojfPtr = - _lookup>('cprojf'); - late final _cprojf = _cprojfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - double crealf( - _Fcomplex _Z, - ) { - return _crealf( - _Z, - ); - } - - late final _crealfPtr = - _lookup>('crealf'); - late final _crealf = _crealfPtr.asFunction(); - - _Fcomplex csinf( - _Fcomplex _Z, - ) { - return _csinf( - _Z, - ); - } - - late final _csinfPtr = - _lookup>('csinf'); - late final _csinf = _csinfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex csinhf( - _Fcomplex _Z, - ) { - return _csinhf( - _Z, - ); - } - - late final _csinhfPtr = - _lookup>('csinhf'); - late final _csinhf = _csinhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex csqrtf( - _Fcomplex _Z, - ) { - return _csqrtf( - _Z, - ); - } - - late final _csqrtfPtr = - _lookup>('csqrtf'); - late final _csqrtf = _csqrtfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex ctanf( - _Fcomplex _Z, - ) { - return _ctanf( - _Z, - ); - } - - late final _ctanfPtr = - _lookup>('ctanf'); - late final _ctanf = _ctanfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - _Fcomplex ctanhf( - _Fcomplex _Z, - ) { - return _ctanhf( - _Z, - ); - } - - late final _ctanhfPtr = - _lookup>('ctanhf'); - late final _ctanhf = _ctanhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>(); - - double normf( - _Fcomplex _Z, - ) { - return _normf( - _Z, - ); - } - - late final _normfPtr = - _lookup>('normf'); - late final _normf = _normfPtr.asFunction(); - - _Dcomplex _Cbuild( - double _Re, - double _Im, - ) { - return __Cbuild( - _Re, - _Im, - ); - } - - late final __CbuildPtr = - _lookup>( - '_Cbuild'); - late final __Cbuild = - __CbuildPtr.asFunction<_Dcomplex Function(double, double)>(); - - _Dcomplex _Cmulcc( - _Dcomplex _X, - _Dcomplex _Y, - ) { - return __Cmulcc( - _X, - _Y, - ); - } - - late final __CmulccPtr = - _lookup>( - '_Cmulcc'); - late final __Cmulcc = - __CmulccPtr.asFunction<_Dcomplex Function(_Dcomplex, _Dcomplex)>(); - - _Dcomplex _Cmulcr( - _Dcomplex _X, - double _Y, - ) { - return __Cmulcr( - _X, - _Y, - ); - } - - late final __CmulcrPtr = - _lookup>( - '_Cmulcr'); - late final __Cmulcr = - __CmulcrPtr.asFunction<_Dcomplex Function(_Dcomplex, double)>(); - - _Fcomplex _FCbuild( - double _Re, - double _Im, - ) { - return __FCbuild( - _Re, - _Im, - ); - } - - late final __FCbuildPtr = - _lookup>( - '_FCbuild'); - late final __FCbuild = - __FCbuildPtr.asFunction<_Fcomplex Function(double, double)>(); - - _Fcomplex _FCmulcc( - _Fcomplex _X, - _Fcomplex _Y, - ) { - return __FCmulcc( - _X, - _Y, - ); - } - - late final __FCmulccPtr = - _lookup>( - '_FCmulcc'); - late final __FCmulcc = - __FCmulccPtr.asFunction<_Fcomplex Function(_Fcomplex, _Fcomplex)>(); - - _Fcomplex _FCmulcr( - _Fcomplex _X, - double _Y, - ) { - return __FCmulcr( - _X, - _Y, - ); - } - - late final __FCmulcrPtr = - _lookup>( - '_FCmulcr'); - late final __FCmulcr = - __FCmulcrPtr.asFunction<_Fcomplex Function(_Fcomplex, double)>(); - - Start_return Start( - ffi.Pointer cfg, - ) { - return _Start( - cfg, - ); - } - - late final _StartPtr = - _lookup)>>( - 'Start'); - late final _Start = - _StartPtr.asFunction)>(); + late final _StartPtr = _lookup)>>('Start'); + late final _Start = _StartPtr.asFunction)>(); void Stop() { return _Stop(); } - late final _StopPtr = - _lookup>('Stop'); + late final _StopPtr = _lookup>('Stop'); late final _Stop = _StopPtr.asFunction(); } -typedef va_list = ffi.Pointer; - -final class __crt_locale_data_public extends ffi.Struct { - external ffi.Pointer _locale_pctype; - - @ffi.Int() - external int _locale_mb_cur_max; - - @ffi.UnsignedInt() - external int _locale_lc_codepage; -} - -final class __crt_locale_pointers extends ffi.Struct { - external ffi.Pointer<__crt_locale_data> locinfo; - - external ffi.Pointer<__crt_multibyte_data> mbcinfo; -} - -final class __crt_locale_data extends ffi.Opaque {} - -final class __crt_multibyte_data extends ffi.Opaque {} - -final class _Mbstatet extends ffi.Struct { - @ffi.UnsignedLong() - external int _Wchar; - - @ffi.UnsignedShort() - external int _Byte; - - @ffi.UnsignedShort() - external int _State; -} - -typedef errno_t = ffi.Int; - -final class _GoString_ extends ffi.Struct { - external ffi.Pointer p; - - @ptrdiff_t() - external int n; -} - -typedef ptrdiff_t = ffi.LongLong; - -final class _C_double_complex extends ffi.Struct { - @ffi.Array.multi([2]) - external ffi.Array _Val; -} - -final class _C_float_complex extends ffi.Struct { - @ffi.Array.multi([2]) - external ffi.Array _Val; -} - -final class _C_ldouble_complex extends ffi.Opaque {} - -typedef _Dcomplex = _C_double_complex; -typedef _Fcomplex = _C_float_complex; - -final class GoInterface extends ffi.Struct { - external ffi.Pointer t; - - external ffi.Pointer v; -} - -final class GoSlice extends ffi.Struct { - external ffi.Pointer data; - - @GoInt() - external int len; - - @GoInt() - external int cap; -} - -typedef GoInt = GoInt64; -typedef GoInt64 = ffi.LongLong; - final class Start_return extends ffi.Struct { - @GoInt() + @ffi.LongLong() external int r0; external ffi.Pointer r1; } - -const int _VCRT_COMPILER_PREPROCESSOR = 1; - -const int _SAL_VERSION = 20; - -const int __SAL_H_VERSION = 180000000; - -const int _USE_DECLSPECS_FOR_SAL = 0; - -const int _USE_ATTRIBUTES_FOR_SAL = 0; - -const int _CRT_PACKING = 8; - -const int _VCRUNTIME_DISABLED_WARNINGS = 4514; - -const int _HAS_EXCEPTIONS = 1; - -const int _WCHAR_T_DEFINED = 1; - -const int NULL = 0; - -const int _HAS_CXX17 = 0; - -const int _HAS_CXX20 = 0; - -const int _HAS_CXX23 = 0; - -const int _HAS_NODISCARD = 1; - -const int _UCRT_DISABLED_WARNINGS = 4324; - -const int _ARGMAX = 100; - -const int _TRUNCATE = -1; - -const int _CRT_INT_MAX = 2147483647; - -const int _CRT_SIZE_MAX = -1; - -const String __FILEW__ = 'C'; - -const int _CRT_FUNCTIONS_REQUIRED = 1; - -const int _CRT_HAS_CXX17 = 0; - -const int _ARM_WINAPI_PARTITION_DESKTOP_SDK_AVAILABLE = 1; - -const int _CRT_BUILD_DESKTOP_APP = 1; - -const int _CRT_INTERNAL_NONSTDC_NAMES = 1; - -const int __STDC_SECURE_LIB__ = 200411; - -const int __GOT_SECURE_LIB__ = 200411; - -const int __STDC_WANT_SECURE_LIB__ = 1; - -const int _SECURECRT_FILL_BUFFER_PATTERN = 254; - -const int _CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES = 0; - -const int _CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES_COUNT = 0; - -const int _CRT_SECURE_CPP_OVERLOAD_SECURE_NAMES = 1; - -const int _CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES_MEMORY = 0; - -const int _CRT_SECURE_CPP_OVERLOAD_SECURE_NAMES_MEMORY = 0; diff --git a/ui/flutter/lib/core/icons/gopeed_icons.dart b/ui/flutter/lib/core/icons/gopeed_icons.dart new file mode 100644 index 00000000..cc33645c --- /dev/null +++ b/ui/flutter/lib/core/icons/gopeed_icons.dart @@ -0,0 +1,29 @@ +// Generated by gopeed-file-category-icons. Do not edit by hand. +import 'package:flutter/widgets.dart'; + +abstract final class GopeedIcons { + static const String _fontFamily = 'GopeedIcons'; + static const IconData protocolBt = IconData(0xe900, fontFamily: _fontFamily); + static const IconData protocolEd2k = IconData(0xe901, fontFamily: _fontFamily); + static const IconData folder = IconData(0xe902, fontFamily: _fontFamily); + static const IconData folderBt = IconData(0xe903, fontFamily: _fontFamily); + static const IconData file = IconData(0xe904, fontFamily: _fontFamily); + static const IconData fileInstaller = IconData(0xe905, fontFamily: _fontFamily); + static const IconData fileAndroid = IconData(0xe906, fontFamily: _fontFamily); + static const IconData fileIos = IconData(0xe907, fontFamily: _fontFamily); + static const IconData fileDiskImage = IconData(0xe908, fontFamily: _fontFamily); + static const IconData fileWeb = IconData(0xe909, fontFamily: _fontFamily); + static const IconData fileText = IconData(0xe90a, fontFamily: _fontFamily); + static const IconData filePdf = IconData(0xe90b, fontFamily: _fontFamily); + static const IconData fileDocument = IconData(0xe90c, fontFamily: _fontFamily); + static const IconData fileSpreadsheet = IconData(0xe90d, fontFamily: _fontFamily); + static const IconData filePresentation = IconData(0xe90e, fontFamily: _fontFamily); + static const IconData fileArchive = IconData(0xe90f, fontFamily: _fontFamily); + static const IconData fileImage = IconData(0xe910, fontFamily: _fontFamily); + static const IconData fileAudio = IconData(0xe911, fontFamily: _fontFamily); + static const IconData fileVideo = IconData(0xe912, fontFamily: _fontFamily); + static const IconData fileCode = IconData(0xe913, fontFamily: _fontFamily); + static const IconData fileEbook = IconData(0xe914, fontFamily: _fontFamily); + static const IconData fileFont = IconData(0xe915, fontFamily: _fontFamily); + static const IconData fileDatabase = IconData(0xe916, fontFamily: _fontFamily); +} diff --git a/ui/flutter/lib/core/network/network_client.dart b/ui/flutter/lib/core/network/network_client.dart new file mode 100644 index 00000000..cc17e954 --- /dev/null +++ b/ui/flutter/lib/core/network/network_client.dart @@ -0,0 +1,3 @@ +class NetworkClient { + const NetworkClient(); +} diff --git a/ui/flutter/lib/core/utils/breakpoints.dart b/ui/flutter/lib/core/utils/breakpoints.dart new file mode 100644 index 00000000..ebbf8a09 --- /dev/null +++ b/ui/flutter/lib/core/utils/breakpoints.dart @@ -0,0 +1,3 @@ +class Breakpoints { + static const double mobile = 720; +} diff --git a/ui/flutter/lib/core/utils/byte_size_formatter.dart b/ui/flutter/lib/core/utils/byte_size_formatter.dart new file mode 100644 index 00000000..b8e6b0c5 --- /dev/null +++ b/ui/flutter/lib/core/utils/byte_size_formatter.dart @@ -0,0 +1,23 @@ +abstract final class ByteSizeFormatter { + static const _units = ['B', 'KB', 'MB', 'GB', 'TB']; + + static String format(int bytes) { + if (bytes <= 0) return '0 B'; + + var value = bytes.toDouble(); + var unitIndex = 0; + while (value >= 1024 && unitIndex < _units.length - 1) { + value /= 1024; + unitIndex++; + } + + // Bytes are whole values. Scaled binary units keep at most two decimal + // places and omit trailing zeroes (for example: 1.5 MB, 1.25 MB, 2 MB). + final formattedValue = unitIndex == 0 ? value.round().toString() : _trimFraction(value.toStringAsFixed(2)); + return '$formattedValue ${_units[unitIndex]}'; + } + + static String _trimFraction(String value) { + return value.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), ''); + } +} diff --git a/ui/flutter/lib/core/utils/content_uri_resolver.dart b/ui/flutter/lib/core/utils/content_uri_resolver.dart new file mode 100644 index 00000000..6f005f9f --- /dev/null +++ b/ui/flutter/lib/core/utils/content_uri_resolver.dart @@ -0,0 +1,6 @@ +import 'content_uri_resolver_stub.dart' if (dart.library.io) 'content_uri_resolver_io.dart' as implementation; + +/// Resolves a platform content URI into a local temporary file. +abstract final class ContentUriResolver { + static Future copyToCache(Uri uri) => implementation.copyToCache(uri); +} diff --git a/ui/flutter/lib/core/utils/content_uri_resolver_io.dart b/ui/flutter/lib/core/utils/content_uri_resolver_io.dart new file mode 100644 index 00000000..47892f18 --- /dev/null +++ b/ui/flutter/lib/core/utils/content_uri_resolver_io.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:uri_content/uri_content.dart'; + +Future copyToCache(Uri uri) async { + if (uri.scheme != 'content') { + throw ArgumentError.value(uri, 'uri', 'Expected an Android content URI'); + } + if (!Platform.isAndroid) { + throw UnsupportedError('content URIs are only supported on Android'); + } + + final importDirectory = Directory(path.join((await getTemporaryDirectory()).path, 'torrent-imports')); + await importDirectory.create(recursive: true); + final output = File(path.join(importDirectory.path, 'gopeed-${DateTime.now().microsecondsSinceEpoch}.torrent')); + final sink = output.openWrite(); + + try { + await sink.addStream(UriContent().getContentStream(uri)); + await sink.close(); + } catch (error) { + await sink.close(); + if (await output.exists()) await output.delete(); + rethrow; + } + + return output.path; +} diff --git a/ui/flutter/lib/core/utils/content_uri_resolver_stub.dart b/ui/flutter/lib/core/utils/content_uri_resolver_stub.dart new file mode 100644 index 00000000..1e5bb28f --- /dev/null +++ b/ui/flutter/lib/core/utils/content_uri_resolver_stub.dart @@ -0,0 +1,3 @@ +Future copyToCache(Uri uri) { + throw UnsupportedError('content URIs are not supported on this platform'); +} diff --git a/ui/flutter/lib/core/utils/file_explorer.dart b/ui/flutter/lib/core/utils/file_explorer.dart new file mode 100644 index 00000000..5e7059ae --- /dev/null +++ b/ui/flutter/lib/core/utils/file_explorer.dart @@ -0,0 +1,7 @@ +import 'file_explorer_stub.dart' if (dart.library.io) 'file_explorer_native.dart' as implementation; + +abstract final class FileExplorer { + static Future open(String filePath) => implementation.open(filePath); + + static Future reveal(String filePath) => implementation.reveal(filePath); +} diff --git a/ui/flutter/lib/core/utils/file_explorer_native.dart b/ui/flutter/lib/core/utils/file_explorer_native.dart new file mode 100644 index 00000000..f3d7b9fa --- /dev/null +++ b/ui/flutter/lib/core/utils/file_explorer_native.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:open_dir/open_dir.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path/path.dart' as path; + +Future open(String filePath) async { + try { + if (!await FileSystemEntity.isFile(filePath) && !await FileSystemEntity.isDirectory(filePath)) { + return false; + } + final result = await OpenFilex.open(filePath); + return result.type == ResultType.done; + } catch (_) { + return false; + } +} + +Future reveal(String filePath) async { + try { + if (!(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { + return open(filePath); + } + + if (await FileSystemEntity.isFile(filePath)) { + return await OpenDir().openNativeDir( + path: path.dirname(filePath), + highlightedFileName: path.basename(filePath), + ) ?? + false; + } + if (await FileSystemEntity.isDirectory(filePath)) { + return await OpenDir().openNativeDir(path: filePath) ?? false; + } + + final parentPath = path.dirname(filePath); + if (await FileSystemEntity.isDirectory(parentPath)) { + return await OpenDir().openNativeDir(path: parentPath) ?? false; + } + return false; + } catch (_) { + return false; + } +} diff --git a/ui/flutter/lib/core/utils/file_explorer_stub.dart b/ui/flutter/lib/core/utils/file_explorer_stub.dart new file mode 100644 index 00000000..30924477 --- /dev/null +++ b/ui/flutter/lib/core/utils/file_explorer_stub.dart @@ -0,0 +1,3 @@ +Future open(String filePath) async => false; + +Future reveal(String filePath) async => false; diff --git a/ui/flutter/lib/core/utils/tcp_port_checker.dart b/ui/flutter/lib/core/utils/tcp_port_checker.dart new file mode 100644 index 00000000..e255b74f --- /dev/null +++ b/ui/flutter/lib/core/utils/tcp_port_checker.dart @@ -0,0 +1,8 @@ +import 'tcp_port_checker_stub.dart' if (dart.library.io) 'tcp_port_checker_native.dart' as implementation; + +/// Returns whether a TCP connection can be established to [host]:[port]. +/// +/// The settings page uses this before saving a changed API listen address, +/// matching the legacy UI's occupied-port check. Unsupported platforms return +/// false so validation never blocks configuration that cannot be probed locally. +Future isTcpPortInUse(String host, int port) => implementation.isTcpPortInUse(host, port); diff --git a/ui/flutter/lib/core/utils/tcp_port_checker_native.dart b/ui/flutter/lib/core/utils/tcp_port_checker_native.dart new file mode 100644 index 00000000..777f7e6b --- /dev/null +++ b/ui/flutter/lib/core/utils/tcp_port_checker_native.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +Future isTcpPortInUse(String host, int port) async { + try { + final socket = await Socket.connect(host, port, timeout: const Duration(seconds: 3)); + await socket.close(); + return true; + } catch (_) { + return false; + } +} diff --git a/ui/flutter/lib/core/utils/tcp_port_checker_stub.dart b/ui/flutter/lib/core/utils/tcp_port_checker_stub.dart new file mode 100644 index 00000000..36bf54b6 --- /dev/null +++ b/ui/flutter/lib/core/utils/tcp_port_checker_stub.dart @@ -0,0 +1 @@ +Future isTcpPortInUse(String host, int port) async => false; diff --git a/ui/flutter/lib/core/utils/text_wrap.dart b/ui/flutter/lib/core/utils/text_wrap.dart new file mode 100644 index 00000000..589d090c --- /dev/null +++ b/ui/flutter/lib/core/utils/text_wrap.dart @@ -0,0 +1,3 @@ +import 'package:flutter/widgets.dart'; + +String softWrapAnywhere(String value) => value.characters.join('\u200B'); diff --git a/ui/flutter/lib/core/utils/transfer_rate_formatter.dart b/ui/flutter/lib/core/utils/transfer_rate_formatter.dart new file mode 100644 index 00000000..becf756a --- /dev/null +++ b/ui/flutter/lib/core/utils/transfer_rate_formatter.dart @@ -0,0 +1,28 @@ +class FormattedTransferRate { + const FormattedTransferRate({required this.value, required this.unit}); + + final String value; + final String unit; + + String get text => '$value $unit'; +} + +abstract final class TransferRateFormatter { + static const _units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s']; + + static FormattedTransferRate format(int bytesPerSecond) { + var value = bytesPerSecond < 0 ? 0.0 : bytesPerSecond.toDouble(); + var unitIndex = 0; + while (value >= 1024 && unitIndex < _units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + final formattedValue = unitIndex == 0 ? value.round().toString() : _trimFraction(value.toStringAsFixed(2)); + return FormattedTransferRate(value: formattedValue, unit: _units[unitIndex]); + } + + static String _trimFraction(String value) { + return value.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), ''); + } +} diff --git a/ui/flutter/lib/core/window/app_window_appearance.dart b/ui/flutter/lib/core/window/app_window_appearance.dart new file mode 100644 index 00000000..4f03ceae --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_appearance.dart @@ -0,0 +1,30 @@ +class AppWindowAppearance { + const AppWindowAppearance({required this.themeMode, required this.themeColor, required this.locale}); + + const AppWindowAppearance.defaults() : themeMode = 'system', themeColor = 'green', locale = ''; + + factory AppWindowAppearance.fromJson(Map json) { + return AppWindowAppearance( + themeMode: (json['themeMode'] ?? 'system').toString(), + themeColor: (json['themeColor'] ?? 'green').toString(), + locale: (json['locale'] ?? '').toString(), + ); + } + + final String themeMode; + final String themeColor; + final String locale; + + Map toJson() => {'themeMode': themeMode, 'themeColor': themeColor, 'locale': locale}; + + @override + bool operator ==(Object other) { + return other is AppWindowAppearance && + other.themeMode == themeMode && + other.themeColor == themeColor && + other.locale == locale; + } + + @override + int get hashCode => Object.hash(themeMode, themeColor, locale); +} diff --git a/ui/flutter/lib/core/window/app_window_bootstrap.dart b/ui/flutter/lib/core/window/app_window_bootstrap.dart new file mode 100644 index 00000000..e23be5e4 --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_bootstrap.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; +import 'package:flutter/material.dart' show Colors; +import 'package:flutter/widgets.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../database/database.dart'; +import '../../l10n/l10n.dart'; +import 'app_window_chrome.dart'; +import 'app_window_payload.dart'; + +class AppWindowBootstrap { + static Future setupMainWindow({bool hidden = false}) async { + if (!AppWindowChrome.isDesktopWindow) { + return; + } + + await windowManager.ensureInitialized(); + + final windowState = Database.instance.getWindowState(); + final windowOptions = WindowOptions( + size: Size(windowState?.width ?? 1024, windowState?.height ?? 768), + center: true, + skipTaskbar: false, + backgroundColor: Colors.transparent, + titleBarStyle: TitleBarStyle.hidden, + ); + + windowManager.waitUntilReadyToShow(windowOptions, () async { + if (AppWindowChrome.usesCustomChrome) { + await windowManager.setAsFrameless(); + } + await windowManager.setHasShadow(true); + if (!hidden) { + await windowManager.show(); + await windowManager.focus(); + } + }); + } + + static Future setupSubWindow(AppWindowType type, {String localeConfig = ''}) async { + if (!AppWindowChrome.isDesktopWindow) { + return; + } + + await windowManager.ensureInitialized(); + final title = subWindowTitle(type, appLocalizationsFor(localeConfig)); + + final options = switch (type) { + AppWindowType.createTask => WindowOptions( + size: Size(820, 620), + center: true, + skipTaskbar: false, + backgroundColor: Colors.transparent, + title: title, + titleBarStyle: TitleBarStyle.hidden, + ), + _ => WindowOptions( + size: Size(900, 640), + center: true, + skipTaskbar: false, + backgroundColor: Colors.transparent, + title: title, + titleBarStyle: TitleBarStyle.hidden, + ), + }; + + windowManager.waitUntilReadyToShow(options, () async { + if (AppWindowChrome.usesCustomChrome) { + await windowManager.setAsFrameless(); + } + await windowManager.setTitle(title); + await windowManager.setHasShadow(true); + await _showSubWindowInForeground(); + }); + } + + static String subWindowTitle(AppWindowType type, AppLocalizations l10n) { + return switch (type) { + AppWindowType.createTask => '${l10n.create} - Gopeed', + _ => l10n.appTitle, + }; + } + + static Future _showSubWindowInForeground() async { + if (defaultTargetPlatform != TargetPlatform.windows) { + await windowManager.show(); + await windowManager.focus(); + return; + } + + // SetForegroundWindow may be rejected after the browser regains focus + // while the child Flutter engine is starting. A brief topmost pulse keeps + // the user-initiated create window visible without making it permanently + // always-on-top. + var pulsing = false; + try { + await windowManager.setAlwaysOnTop(true); + pulsing = true; + await windowManager.show(); + await windowManager.focus(); + await Future.delayed(const Duration(milliseconds: 180)); + } finally { + if (pulsing) { + await windowManager.setAlwaysOnTop(false); + } + } + await windowManager.focus(); + } +} diff --git a/ui/flutter/lib/core/window/app_window_chrome.dart b/ui/flutter/lib/core/window/app_window_chrome.dart new file mode 100644 index 00000000..d670096e --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_chrome.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; + +abstract final class AppWindowChrome { + static bool get isDesktopWindow => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux); + + static bool get usesCustomChrome => + !kIsWeb && (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux); + + static bool get reservesHeaderInset => isDesktopWindow; + + static bool get clipsRoundedCorners => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; +} diff --git a/ui/flutter/lib/core/window/app_window_frame.dart b/ui/flutter/lib/core/window/app_window_frame.dart new file mode 100644 index 00000000..516e03c6 --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_frame.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart' show Colors; +import 'package:flutter/widgets.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../shared/widgets/window/desktop_window_header.dart'; +import 'app_window_chrome.dart'; + +const double kWindowCornerRadius = 12; + +class AppWindowFrame extends StatelessWidget { + const AppWindowFrame({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + Widget framedChild = child; + + if (AppWindowChrome.usesCustomChrome) { + framedChild = Stack( + children: [ + Positioned.fill(child: child), + const Positioned(top: 0, left: 0, right: 0, child: DesktopWindowHeader()), + ], + ); + } + + if (AppWindowChrome.clipsRoundedCorners) { + framedChild = ClipRRect(borderRadius: BorderRadius.circular(kWindowCornerRadius), child: framedChild); + } + + if (AppWindowChrome.clipsRoundedCorners) { + framedChild = DecoratedBox( + decoration: const BoxDecoration(color: Colors.transparent), + child: framedChild, + ); + } + + if (AppWindowChrome.usesCustomChrome) { + framedChild = DragToResizeArea(resizeEdgeSize: 6, child: framedChild); + } + + return framedChild; + } +} diff --git a/ui/flutter/lib/core/window/app_window_launch_context.dart b/ui/flutter/lib/core/window/app_window_launch_context.dart new file mode 100644 index 00000000..8d692948 --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_launch_context.dart @@ -0,0 +1,36 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; + +import 'app_window_payload.dart'; + +class AppWindowLaunchContext { + const AppWindowLaunchContext._({required this.isSubWindow, required this.payload, this.windowId}); + + factory AppWindowLaunchContext.main() { + return AppWindowLaunchContext._(isSubWindow: false, payload: AppWindowPayload.main()); + } + + factory AppWindowLaunchContext.subWindow({required String windowId, required AppWindowPayload payload}) { + return AppWindowLaunchContext._(isSubWindow: true, windowId: windowId, payload: payload); + } + + factory AppWindowLaunchContext.fromArgs(List args) { + if (args.length >= 2 && args.first == 'multi_window') { + final windowId = args[1]; + final payload = args.length >= 3 ? AppWindowPayload.fromRaw(args[2]) : AppWindowPayload.main(); + return AppWindowLaunchContext.subWindow(windowId: windowId, payload: payload); + } + + return AppWindowLaunchContext.main(); + } + + final bool isSubWindow; + final String? windowId; + final AppWindowPayload payload; + + WindowController? buildController() { + if (!isSubWindow || windowId == null) { + return null; + } + return WindowController.fromWindowId(windowId!); + } +} diff --git a/ui/flutter/lib/core/window/app_window_launcher.dart b/ui/flutter/lib/core/window/app_window_launcher.dart new file mode 100644 index 00000000..72a70557 --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_launcher.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/foundation.dart'; + +import '../../api/model/create_task.dart'; +import '../../util/log_util.dart'; +import '../capabilities/app_capabilities.dart'; +import 'app_window_payload.dart'; +import 'window_capability_transport.dart'; + +class AppWindowLauncher { + static Future openCreateTaskWindow({CreateTask? createTask}) async { + if (kIsWeb || !(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { + return false; + } + + try { + await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry); + final payload = AppWindowPayload.createTask(createTask: createTask?.toJson()); + // Every request deliberately creates a new child window. Create-task + // windows are independent and are never reused. + await WindowController.create(WindowConfiguration(arguments: payload.toRaw(), hiddenAtLaunch: true)); + // Keep the native window hidden until the child engine has applied its + // final size, centered position, chrome, and foreground behavior in + // AppWindowBootstrap.setupSubWindow. + return true; + } catch (error, stackTrace) { + try { + logger.w('create task window failed', error, stackTrace); + } catch (_) { + debugPrint('create task window failed: $error'); + } + return false; + } + } +} diff --git a/ui/flutter/lib/core/window/app_window_payload.dart b/ui/flutter/lib/core/window/app_window_payload.dart new file mode 100644 index 00000000..c60eda2d --- /dev/null +++ b/ui/flutter/lib/core/window/app_window_payload.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +enum AppWindowType { main, createTask } + +class AppWindowPayload { + const AppWindowPayload({required this.type, this.data = const {}}); + + factory AppWindowPayload.main() => const AppWindowPayload(type: AppWindowType.main); + + factory AppWindowPayload.createTask({Map? createTask}) => AppWindowPayload( + type: AppWindowType.createTask, + data: { + ...?createTask == null ? null : {'createTask': createTask}, + }, + ); + + factory AppWindowPayload.fromRaw(String raw) { + if (raw.trim().isEmpty) { + return AppWindowPayload.main(); + } + + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + final type = switch ((decoded['type'] ?? '').toString()) { + 'create_task' => AppWindowType.createTask, + _ => AppWindowType.main, + }; + return AppWindowPayload(type: type, data: decoded); + } + } catch (_) { + // Ignore invalid payload and fallback to main. + } + + return AppWindowPayload.main(); + } + + final AppWindowType type; + final Map data; + + Map? get createTask { + final raw = data['createTask']; + return raw is Map ? raw : null; + } + + String toRaw() { + return jsonEncode({ + 'type': switch (type) { + AppWindowType.main => 'main', + AppWindowType.createTask => 'create_task', + }, + ...data, + }); + } +} diff --git a/ui/flutter/lib/core/window/window_capability_transport.dart b/ui/flutter/lib/core/window/window_capability_transport.dart new file mode 100644 index 00000000..45506801 --- /dev/null +++ b/ui/flutter/lib/core/window/window_capability_transport.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../capabilities/app_capabilities.dart'; +import '../capabilities/capability_rpc.dart'; +import 'app_window_appearance.dart'; + +abstract final class AppWindowRpcProtocol { + static const channelName = 'gopeed.app.capabilities.v1'; + static const call = 'capability.call'; + static const bootstrap = 'window.bootstrap'; + static const subscribe = 'window.subscribe'; + static const unsubscribe = 'window.unsubscribe'; + static const event = 'window.event'; + static const appearanceChanged = 'appearance.changed'; +} + +const _hostChannel = WindowMethodChannel(AppWindowRpcProtocol.channelName, mode: ChannelMode.unidirectional); + +class WindowCapabilityInvoker implements CapabilityInvoker { + const WindowCapabilityInvoker(this.codecs); + + final RpcCodecRegistry codecs; + + @override + Future invoke(RpcMethod method, P params) async { + final raw = await _hostChannel.invokeMethod(AppWindowRpcProtocol.call, { + 'operation': method.name, + 'payload': codecs.encode(params), + }); + final response = codecs.decode>(raw); + if (response['ok'] != true) { + final error = codecs.decode>(response['error']); + throw CapabilityException( + (error['code'] ?? 'remote_error').toString(), + (error['message'] ?? 'Remote capability failed').toString(), + error['details'], + ); + } + return codecs.decode(response['data']); + } +} + +class AppWindowCapabilityHost { + AppWindowCapabilityHost._(); + + static final instance = AppWindowCapabilityHost._(); + + CapabilityRegistry? _registry; + AppWindowAppearance _appearance = const AppWindowAppearance.defaults(); + final Map _subscribers = {}; + + bool get started => _registry != null; + + Future start(CapabilityRegistry registry) async { + if (_registry != null) return; + _registry = registry; + await _hostChannel.setMethodCallHandler(_handleCall); + } + + Future stop() async { + _registry = null; + _subscribers.clear(); + await _hostChannel.setMethodCallHandler(null); + } + + void updateAppearance(AppWindowAppearance appearance) { + if (_appearance == appearance) return; + _appearance = appearance; + unawaited(_broadcastAppearance()); + } + + Future _handleCall(MethodCall call) async { + try { + switch (call.method) { + case AppWindowRpcProtocol.call: + final args = _map(call.arguments); + final registry = _registry; + if (registry == null) { + throw const CapabilityException('host_unavailable', 'Main window capabilities are not ready'); + } + final data = await registry.invoke((args['operation'] ?? '').toString(), args['payload']); + return {'ok': true, 'data': data}; + case AppWindowRpcProtocol.bootstrap: + return _appearance.toJson(); + case AppWindowRpcProtocol.subscribe: + final windowId = (_map(call.arguments)['windowId'] ?? '').toString(); + if (windowId.isEmpty) { + throw const CapabilityException('invalid_window', 'Missing child window id'); + } + final controller = WindowController.fromWindowId(windowId); + _subscribers[windowId] = controller; + scheduleMicrotask(() => _sendAppearance(windowId, controller)); + return null; + case AppWindowRpcProtocol.unsubscribe: + final windowId = (_map(call.arguments)['windowId'] ?? '').toString(); + _subscribers.remove(windowId); + return null; + default: + throw CapabilityException('method_not_found', 'Window RPC method not found: ${call.method}'); + } + } on CapabilityException catch (error) { + if (call.method == AppWindowRpcProtocol.call) { + return { + 'ok': false, + 'error': {'code': error.code, 'message': error.message, 'details': error.details}, + }; + } + rethrow; + } catch (error) { + if (call.method == AppWindowRpcProtocol.call) { + return { + 'ok': false, + 'error': {'code': 'internal_error', 'message': error.toString()}, + }; + } + rethrow; + } + } + + Future _broadcastAppearance() async { + final subscribers = Map.of(_subscribers); + await Future.wait([for (final entry in subscribers.entries) _sendAppearance(entry.key, entry.value)]); + } + + Future _sendAppearance(String windowId, WindowController controller) async { + try { + await controller.invokeMethod(AppWindowRpcProtocol.event, { + 'name': AppWindowRpcProtocol.appearanceChanged, + 'data': _appearance.toJson(), + }); + } catch (_) { + _subscribers.remove(windowId); + } + } + + Map _map(Object? value) { + if (value is! Map) return const {}; + return {for (final entry in value.entries) entry.key.toString(): entry.value}; + } +} + +class ChildWindowSession { + ChildWindowSession._(this.controller, this.codecs) + : capabilities = AppCapabilities(WindowCapabilityInvoker(codecs)), + appearance = ValueNotifier(const AppWindowAppearance.defaults()); + + static Future connect(WindowController controller) async { + final session = ChildWindowSession._(controller, createAppCapabilityCodecs()); + await session._initialize(); + return session; + } + + final WindowController controller; + final RpcCodecRegistry codecs; + final AppCapabilities capabilities; + final ValueNotifier appearance; + + Future _initialize() async { + await controller.setWindowMethodHandler(_handleWindowCall); + final initial = await _hostChannel.invokeMethod(AppWindowRpcProtocol.bootstrap); + appearance.value = codecs.decode(initial); + await _hostChannel.invokeMethod(AppWindowRpcProtocol.subscribe, {'windowId': controller.windowId}); + } + + Future _handleWindowCall(MethodCall call) async { + if (call.method != AppWindowRpcProtocol.event) return null; + final args = codecs.decode>(call.arguments); + if (args['name'] == AppWindowRpcProtocol.appearanceChanged) { + appearance.value = codecs.decode(args['data']); + } + return null; + } + + void dispose() { + unawaited(controller.setWindowMethodHandler(null)); + unawaited(_hostChannel.invokeMethod(AppWindowRpcProtocol.unsubscribe, {'windowId': controller.windowId})); + appearance.dispose(); + } +} diff --git a/ui/flutter/lib/database/database.dart b/ui/flutter/lib/database/database.dart index 92b57869..a62cbf85 100644 --- a/ui/flutter/lib/database/database.dart +++ b/ui/flutter/lib/database/database.dart @@ -9,7 +9,6 @@ const String _startConfig = 'startConfig'; const String _windowState = 'windowState'; const String _bookmark = 'bookmark'; const String _createHistory = 'createHistory'; -const String _webToken = 'webToken'; const String _runAsMenubarApp = 'runAsMenubarApp'; const String _analyticsEnabled = 'analyticsEnabled'; const String _analyticsClientId = 'analyticsClientId'; @@ -24,12 +23,19 @@ class Database { } late Box box; + bool _initialized = false; Database._internal(); + bool get initialized => _initialized; + Future init() async { + if (_initialized) { + return; + } Hive.init(Util.getStorageDir()); box = await Hive.openBox('database'); + _initialized = true; } void save(String key, T entity) { @@ -53,8 +59,7 @@ class Database { } StartConfigEntity? getStartConfig() { - return get( - _startConfig, (json) => StartConfigEntity.fromJson(json)); + return get(_startConfig, (json) => StartConfigEntity.fromJson(json)); } /// Patch non-null fields with the original value @@ -67,8 +72,7 @@ class Database { } WindowStateEntity? getWindowState() { - return get( - _windowState, (json) => WindowStateEntity.fromJson(json)); + return get(_windowState, (json) => WindowStateEntity.fromJson(json)); } /// Use map to ensure that the same directory only saves the latest bookmark @@ -80,19 +84,10 @@ class Database { Map? getBookmark() { return get>(_bookmark, (json) { - return (json as Map) - .map((key, value) => MapEntry(key, value.toString())); + return (json as Map).map((key, value) => MapEntry(key, value.toString())); }); } - void saveWebToken(String token) { - save(_webToken, token); - } - - String? getWebToken() { - return get(_webToken, (json) => json.toString()); - } - void saveCreateHistory(String url) { var list = getCreateHistory() ?? []; list.remove(url); @@ -109,6 +104,16 @@ class Database { }); } + void removeCreateHistory(String url) { + final list = getCreateHistory() ?? []; + list.remove(url); + if (list.isEmpty) { + clearCreateHistory(); + } else { + save>(_createHistory, list); + } + } + void clearCreateHistory() { clear(_createHistory); } diff --git a/ui/flutter/lib/database/entity.dart b/ui/flutter/lib/database/entity.dart index 6cb5273e..a30a5ec3 100644 --- a/ui/flutter/lib/database/entity.dart +++ b/ui/flutter/lib/database/entity.dart @@ -8,14 +8,9 @@ class StartConfigEntity { String address; String apiToken; - StartConfigEntity({ - required this.network, - required this.address, - required this.apiToken, - }); + StartConfigEntity({required this.network, required this.address, required this.apiToken}); - factory StartConfigEntity.fromJson(Map json) => - _$StartConfigEntityFromJson(json); + factory StartConfigEntity.fromJson(Map json) => _$StartConfigEntityFromJson(json); Map toJson() => _$StartConfigEntityToJson(this); } @@ -25,13 +20,8 @@ class WindowStateEntity { double? width; double? height; - WindowStateEntity({ - this.isMaximized, - this.width, - this.height, - }); + WindowStateEntity({this.isMaximized, this.width, this.height}); - factory WindowStateEntity.fromJson(Map json) => - _$WindowStateEntityFromJson(json); + factory WindowStateEntity.fromJson(Map json) => _$WindowStateEntityFromJson(json); Map toJson() => _$WindowStateEntityToJson(this); } diff --git a/ui/flutter/lib/database/entity.g.dart b/ui/flutter/lib/database/entity.g.dart index e13f12ab..90876475 100644 --- a/ui/flutter/lib/database/entity.g.dart +++ b/ui/flutter/lib/database/entity.g.dart @@ -27,17 +27,9 @@ WindowStateEntity _$WindowStateEntityFromJson(Map json) => height: (json['height'] as num?)?.toDouble(), ); -Map _$WindowStateEntityToJson(WindowStateEntity instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('isMaximized', instance.isMaximized); - writeNotNull('width', instance.width); - writeNotNull('height', instance.height); - return val; -} +Map _$WindowStateEntityToJson(WindowStateEntity instance) => + { + 'isMaximized': ?instance.isMaximized, + 'width': ?instance.width, + 'height': ?instance.height, + }; diff --git a/ui/flutter/lib/features/auth/application/web_auth_controller.dart b/ui/flutter/lib/features/auth/application/web_auth_controller.dart new file mode 100644 index 00000000..bd790040 --- /dev/null +++ b/ui/flutter/lib/features/auth/application/web_auth_controller.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/api.dart' as api; +import '../../../api/model/login.dart'; + +final webAuthControllerProvider = Provider((ref) { + final controller = WebAuthController(); + ref.onDispose(controller.dispose); + return controller; +}); + +enum WebLoginFailure { credentials, network } + +class WebAuthController extends ChangeNotifier { + bool _loginRequired = false; + bool _isLoggingIn = false; + + bool get loginRequired => _loginRequired; + bool get isLoggingIn => _isLoggingIn; + + void requireLogin() { + if (_loginRequired) return; + _loginRequired = true; + notifyListeners(); + } + + Future login({required String username, required String password}) async { + if (_isLoggingIn) return null; + _isLoggingIn = true; + notifyListeners(); + try { + await api.login(LoginReq(username: username, password: password)); + return null; + } on api.ApiTimeoutException { + return WebLoginFailure.network; + } catch (_) { + return WebLoginFailure.credentials; + } finally { + _isLoggingIn = false; + notifyListeners(); + } + } + + void completeLogin() { + if (!_loginRequired) return; + _loginRequired = false; + notifyListeners(); + } +} diff --git a/ui/flutter/lib/features/auth/presentation/pages/login_page.dart b/ui/flutter/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 00000000..197437a0 --- /dev/null +++ b/ui/flutter/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,236 @@ +import 'dart:math' as math; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../app/application/app_runtime_controller.dart'; +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../application/web_auth_controller.dart'; +import '../widgets/login_brand_art.dart'; +import '../widgets/login_form.dart'; + +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _usernameFocusNode = FocusNode(); + final _passwordFocusNode = FocusNode(); + + String? _usernameError; + String? _passwordError; + WebLoginFailure? _loginFailure; + bool _completingLogin = false; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _usernameFocusNode.dispose(); + _passwordFocusNode.dispose(); + super.dispose(); + } + + Future _submit() async { + final username = _usernameController.text.trim(); + final password = _passwordController.text; + setState(() { + _usernameError = username.isEmpty ? context.l10n.username_required : null; + _passwordError = password.isEmpty ? context.l10n.password_required : null; + _loginFailure = null; + }); + if (_usernameError != null || _passwordError != null) { + if (_usernameError != null) { + _usernameFocusNode.requestFocus(); + } else { + _passwordFocusNode.requestFocus(); + } + return; + } + + final authController = ref.read(webAuthControllerProvider); + final failure = await authController.login(username: username, password: password); + if (!mounted) return; + if (failure != null) { + setState(() => _loginFailure = failure); + return; + } + + setState(() => _completingLogin = true); + try { + await ref.read(appRuntimeControllerProvider.notifier).reloadConfig(); + if (!mounted) return; + TextInput.finishAutofillContext(shouldSave: true); + authController.completeLogin(); + } catch (_) { + authController.requireLogin(); + if (!mounted) return; + setState(() => _loginFailure = WebLoginFailure.network); + } finally { + if (mounted) setState(() => _completingLogin = false); + } + } + + @override + Widget build(BuildContext context) { + final authController = ref.read(webAuthControllerProvider); + return ListenableBuilder( + listenable: authController, + builder: (context, _) { + final loading = authController.isLoggingIn || _completingLogin; + return _LoginPageLayout( + form: LoginForm( + usernameController: _usernameController, + passwordController: _passwordController, + usernameFocusNode: _usernameFocusNode, + passwordFocusNode: _passwordFocusNode, + loading: loading, + usernameError: _usernameError, + passwordError: _passwordError, + loginError: switch (_loginFailure) { + WebLoginFailure.credentials => context.l10n.login_failed, + WebLoginFailure.network => context.l10n.login_failed_network, + null => null, + }, + onUsernameChanged: (_) { + if (_usernameError == null && _loginFailure == null) return; + setState(() { + _usernameError = null; + _loginFailure = null; + }); + }, + onPasswordChanged: (_) { + if (_passwordError == null && _loginFailure == null) return; + setState(() { + _passwordError = null; + _loginFailure = null; + }); + }, + onSubmit: _submit, + ), + ); + }, + ); + } +} + +class _LoginPageLayout extends StatelessWidget { + const _LoginPageLayout({required this.form}); + + final Widget form; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final topInset = AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0.0; + return shad.Scaffold( + child: ColoredBox( + color: palette.bg, + child: Padding( + padding: EdgeInsets.only(top: topInset), + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, constraints) { + final desktop = constraints.maxWidth >= 900; + final horizontalPadding = desktop ? AppDesignTokens.space32 : AppDesignTokens.space16; + final verticalPadding = desktop ? AppDesignTokens.space24 : AppDesignTokens.space16; + final availableWidth = math.max(0.0, constraints.maxWidth - horizontalPadding * 2); + final cardWidth = math.min(desktop ? 980.0 : 440.0, availableWidth); + final card = SizedBox( + width: cardWidth, + child: desktop ? _DesktopLoginCard(form: form) : _CompactLoginCard(form: form), + ); + if (desktop) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: verticalPadding), + child: Center( + child: FittedBox(fit: BoxFit.scaleDown, child: card), + ), + ); + } + return Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: verticalPadding), + child: Center( + child: FittedBox(fit: BoxFit.scaleDown, child: card), + ), + ); + }, + ), + ), + ), + ), + ); + } +} + +class _DesktopLoginCard extends StatelessWidget { + const _DesktopLoginCard({required this.form}); + + final Widget form; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return shad.Card( + key: const ValueKey('login-desktop-card'), + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(AppDesignTokens.windowRadius), + borderColor: palette.border, + boxShadow: [ + BoxShadow(color: palette.textPrimary.withValues(alpha: 0.08), blurRadius: 36, offset: const Offset(0, 14)), + ], + clipBehavior: Clip.antiAlias, + child: Row( + children: [ + const Expanded(child: SizedBox(height: 520, child: LoginBrandArt())), + SizedBox( + width: 420, + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 56), child: form), + ), + ], + ), + ); + } +} + +class _CompactLoginCard extends StatelessWidget { + const _CompactLoginCard({required this.form}); + + final Widget form; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: shad.Card( + key: const ValueKey('login-compact-card'), + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(AppDesignTokens.windowRadius), + borderColor: palette.border, + boxShadow: [ + BoxShadow(color: palette.textPrimary.withValues(alpha: 0.08), blurRadius: 28, offset: const Offset(0, 10)), + ], + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 190, child: LoginBrandArt(compact: true)), + Padding(padding: const EdgeInsets.all(AppDesignTokens.space24), child: form), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/auth/presentation/widgets/login_brand_art.dart b/ui/flutter/lib/features/auth/presentation/widgets/login_brand_art.dart new file mode 100644 index 00000000..cdbc7245 --- /dev/null +++ b/ui/flutter/lib/features/auth/presentation/widgets/login_brand_art.dart @@ -0,0 +1,238 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/gopeed_app_mark.dart'; + +class LoginBrandArt extends StatelessWidget { + const LoginBrandArt({super.key, this.compact = false}); + + final bool compact; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: AlignmentDirectional.topStart, + end: AlignmentDirectional.bottomEnd, + colors: [palette.sideBg, Color.alphaBlend(palette.brandSoft, palette.sideBg), palette.cardBg], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: _TransferFlowPainter(palette: palette, compact: compact), + ), + PositionedDirectional( + start: compact ? AppDesignTokens.space24 : AppDesignTokens.space32, + top: compact ? AppDesignTokens.space16 : AppDesignTokens.space32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Gopeed', + style: TextStyle( + color: palette.textPrimary, + fontSize: compact ? 17 : 19, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + ), + ), + ], + ), + ), + Center(child: _TransferOrbit(compact: compact)), + ], + ), + ); + } +} + +class _TransferOrbit extends StatelessWidget { + const _TransferOrbit({required this.compact}); + + final bool compact; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final size = compact ? 132.0 : 260.0; + final coreSize = compact ? 62.0 : 96.0; + return SizedBox.square( + dimension: size, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + CustomPaint( + size: Size.square(size), + painter: _OrbitPainter(palette: palette), + ), + Container( + width: coreSize, + height: coreSize, + padding: EdgeInsets.all(compact ? 7 : 11), + decoration: BoxDecoration( + color: palette.cardBg, + shape: BoxShape.circle, + border: Border.all(color: palette.brand.withValues(alpha: 0.42)), + boxShadow: [ + BoxShadow(color: palette.brand.withValues(alpha: 0.18), blurRadius: compact ? 18 : 32, spreadRadius: 2), + ], + ), + child: GopeedAppMark(size: coreSize - (compact ? 14 : 22)), + ), + PositionedDirectional( + top: compact ? 5 : 16, + end: compact ? 3 : 20, + child: _OrbitNode(icon: shad.LucideIcons.cloudDownload, compact: compact), + ), + PositionedDirectional( + bottom: compact ? 7 : 20, + start: compact ? 0 : 14, + child: _OrbitNode(icon: shad.LucideIcons.hardDriveDownload, compact: compact), + ), + if (!compact) + PositionedDirectional( + bottom: 4, + end: 38, + child: _OrbitNode(icon: shad.LucideIcons.gauge, compact: false, small: true), + ), + ], + ), + ); + } +} + +class _OrbitNode extends StatelessWidget { + const _OrbitNode({required this.icon, required this.compact, this.small = false}); + + final IconData icon; + final bool compact; + final bool small; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final dimension = small + ? 34.0 + : compact + ? 30.0 + : 42.0; + return Container( + width: dimension, + height: dimension, + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius + 4), + border: Border.all(color: palette.border), + boxShadow: [ + BoxShadow(color: palette.textPrimary.withValues(alpha: 0.08), blurRadius: 12, offset: const Offset(0, 4)), + ], + ), + child: Icon( + icon, + size: small + ? 15 + : compact + ? 14 + : 18, + color: palette.brand, + ), + ); + } +} + +class _TransferFlowPainter extends CustomPainter { + const _TransferFlowPainter({required this.palette, required this.compact}); + + final AppPalette palette; + final bool compact; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final dotPaint = Paint()..color = palette.textMuted.withValues(alpha: 0.12); + final gap = compact ? 24.0 : 28.0; + for (var x = gap / 2; x < size.width; x += gap) { + for (var y = gap / 2; y < size.height; y += gap) { + canvas.drawCircle(Offset(x, y), 1, dotPaint); + } + } + + final paths = [ + Path() + ..moveTo(-size.width * 0.08, size.height * 0.32) + ..cubicTo(size.width * 0.20, size.height * 0.18, center.dx * 0.65, center.dy * 0.72, center.dx, center.dy), + Path() + ..moveTo(size.width * 1.08, size.height * 0.72) + ..cubicTo(size.width * 0.78, size.height * 0.84, center.dx * 1.35, center.dy * 1.18, center.dx, center.dy), + Path() + ..moveTo(size.width * 0.18, size.height * 1.06) + ..cubicTo(size.width * 0.26, size.height * 0.72, center.dx * 0.72, center.dy * 1.28, center.dx, center.dy), + ]; + for (var index = 0; index < paths.length; index++) { + canvas.drawPath( + paths[index], + Paint() + ..color = (index == 0 ? palette.brand : palette.textMuted).withValues(alpha: index == 0 ? 0.28 : 0.13) + ..style = PaintingStyle.stroke + ..strokeWidth = index == 0 ? 1.8 : 1.0, + ); + } + + final glowPaint = Paint() + ..shader = RadialGradient( + colors: [palette.brand.withValues(alpha: 0.13), palette.brand.withValues(alpha: 0)], + ).createShader(Rect.fromCircle(center: center, radius: compact ? 90 : 180)); + canvas.drawCircle(center, compact ? 90 : 180, glowPaint); + } + + @override + bool shouldRepaint(_TransferFlowPainter oldDelegate) => + oldDelegate.palette != palette || oldDelegate.compact != compact; +} + +class _OrbitPainter extends CustomPainter { + const _OrbitPainter({required this.palette}); + + final AppPalette palette; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.shortestSide * 0.39; + canvas.drawCircle( + center, + radius, + Paint() + ..color = palette.brand.withValues(alpha: 0.26) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius * 0.72), + -math.pi * 0.85, + math.pi * 1.38, + false, + Paint() + ..color = palette.brand.withValues(alpha: 0.52) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round, + ); + for (final angle in [-0.72, 0.18, 0.76]) { + final point = center + Offset(math.cos(angle * math.pi), math.sin(angle * math.pi)) * radius; + canvas.drawCircle(point, 3.2, Paint()..color = palette.brand); + } + } + + @override + bool shouldRepaint(_OrbitPainter oldDelegate) => oldDelegate.palette != palette; +} diff --git a/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart b/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart new file mode 100644 index 00000000..55b40220 --- /dev/null +++ b/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart @@ -0,0 +1,190 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; + +class LoginForm extends StatelessWidget { + const LoginForm({ + super.key, + required this.usernameController, + required this.passwordController, + required this.usernameFocusNode, + required this.passwordFocusNode, + required this.loading, + required this.onSubmit, + required this.onUsernameChanged, + required this.onPasswordChanged, + this.usernameError, + this.passwordError, + this.loginError, + }); + + final TextEditingController usernameController; + final TextEditingController passwordController; + final FocusNode usernameFocusNode; + final FocusNode passwordFocusNode; + final bool loading; + final VoidCallback onSubmit; + final ValueChanged onUsernameChanged; + final ValueChanged onPasswordChanged; + final String? usernameError; + final String? passwordError; + final String? loginError; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return AutofillGroup( + onDisposeAction: AutofillContextAction.cancel, + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.login, + key: const ValueKey('login-title'), + style: TextStyle(color: palette.textPrimary, fontSize: 30, fontWeight: FontWeight.w700, height: 1.15), + ), + const SizedBox(height: AppDesignTokens.space24), + _LoginField( + fieldKey: const ValueKey('login-username-field'), + label: context.l10n.username, + controller: usernameController, + focusNode: usernameFocusNode, + autofocus: true, + enabled: !loading, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.username], + error: usernameError, + onChanged: onUsernameChanged, + onSubmitted: (_) => onSubmit(), + ), + const SizedBox(height: AppDesignTokens.space16), + _LoginField( + fieldKey: const ValueKey('login-password-field'), + label: context.l10n.password, + controller: passwordController, + focusNode: passwordFocusNode, + enabled: !loading, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + obscureText: true, + error: passwordError, + onChanged: onPasswordChanged, + onSubmitted: (_) => onSubmit(), + ), + AnimatedSize( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + child: loginError == null + ? const SizedBox(height: AppDesignTokens.space24) + : Padding( + padding: const EdgeInsets.only(top: AppDesignTokens.space12, bottom: AppDesignTokens.space12), + child: Text( + loginError!, + key: const ValueKey('login-error'), + style: TextStyle(color: palette.error, fontSize: 12, height: 1.35), + ), + ), + ), + Center( + child: SizedBox( + key: const ValueKey('login-submit-button-container'), + width: 148, + height: 40, + child: AppLoadingButton( + key: const ValueKey('login-submit-button'), + onPressed: loading ? null : onSubmit, + loading: loading, + alignment: Alignment.center, + variant: AppLoadingButtonVariant.primary, + child: Text(context.l10n.login, textAlign: TextAlign.center), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _LoginField extends StatelessWidget { + const _LoginField({ + required this.fieldKey, + required this.label, + required this.controller, + required this.focusNode, + required this.enabled, + required this.keyboardType, + required this.textInputAction, + required this.autofillHints, + required this.onChanged, + required this.onSubmitted, + this.autofocus = false, + this.obscureText = false, + this.error, + }); + + final Key fieldKey; + final String label; + final TextEditingController controller; + final FocusNode focusNode; + final bool enabled; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final Iterable autofillHints; + final ValueChanged onChanged; + final ValueChanged onSubmitted; + final bool autofocus; + final bool obscureText; + final String? error; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + label, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: AppDesignTokens.space8), + shad.TextField( + key: fieldKey, + controller: controller, + focusNode: focusNode, + autofocus: autofocus, + enabled: enabled, + keyboardType: keyboardType, + textInputAction: textInputAction, + autofillHints: autofillHints, + autocorrect: false, + enableSuggestions: false, + obscureText: obscureText, + onChanged: onChanged, + onSubmitted: onSubmitted, + border: Border.all(color: error == null ? palette.border : palette.error), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + filled: true, + features: obscureText + ? const [shad.InputPasswordToggleFeature(mode: shad.PasswordPeekMode.toggle)] + : const [], + ), + if (error != null) ...[ + const SizedBox(height: AppDesignTokens.space4), + Text(error!, style: TextStyle(color: palette.error, fontSize: 12, height: 1.3)), + ], + ], + ); + } +} diff --git a/ui/flutter/lib/features/extensions/application/extensions_controller.dart b/ui/flutter/lib/features/extensions/application/extensions_controller.dart new file mode 100644 index 00000000..74e439d3 --- /dev/null +++ b/ui/flutter/lib/features/extensions/application/extensions_controller.dart @@ -0,0 +1,377 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/gopeed_site_api.dart'; +import '../../../api/model/extension.dart'; +import '../../../api/model/install_extension.dart'; +import '../../../api/model/store_extension.dart'; +import '../../../api/model/switch_extension.dart'; +import '../../../api/model/update_extension_settings.dart'; +import '../../../core/capabilities/app_capabilities.dart'; + +final extensionsControllerProvider = AsyncNotifierProvider( + ExtensionsController.new, +); + +enum ExtensionListFilter { market, installed } + +class ExtensionListItem { + const ExtensionListItem({this.installed, this.store}); + + final Extension? installed; + final StoreExtension? store; + + bool get isInstalled => installed != null; + String get id => installed?.identity ?? store!.id; + String get title => installed?.title ?? store?.title ?? ''; + String get author => installed?.author ?? store?.author ?? ''; + String get description => installed?.description ?? store?.description ?? ''; + String get version => installed?.version ?? store?.version ?? '0.0.0'; + String? get homepage => installed?.homepage.isNotEmpty == true ? installed!.homepage : store?.homepage; + String? get repoUrl => installed?.repository?.url.isNotEmpty == true ? installed!.repository!.url : store?.repoUrl; + int get stars => store?.stars ?? 0; + int get installCount => store?.installCount ?? 0; +} + +class ExtensionsState { + const ExtensionsState({ + this.installedExtensions = const [], + this.storeExtensions = const [], + this.updateFlags = const {}, + this.storePagination, + this.storeQuery = '', + this.storeSort = StoreExtensionSort.stars, + this.listFilter = ExtensionListFilter.market, + this.loadingInstalled = false, + this.loadingStore = false, + this.loadingMoreStore = false, + this.busyExtensionIds = const {}, + this.devMode = false, + }); + + final List installedExtensions; + final List storeExtensions; + final Map updateFlags; + final StorePagination? storePagination; + final String storeQuery; + final StoreExtensionSort storeSort; + final ExtensionListFilter listFilter; + final bool loadingInstalled; + final bool loadingStore; + final bool loadingMoreStore; + final Set busyExtensionIds; + final bool devMode; + + UnmodifiableMapView get installedMap => + UnmodifiableMapView({for (final ext in installedExtensions) ext.identity: ext}); + + UnmodifiableMapView get storeMap => + UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext}); + + List get displayItems { + if (listFilter == ExtensionListFilter.installed) { + return installedExtensions + .map((ext) => ExtensionListItem(installed: ext, store: storeMap[ext.identity])) + .toList(); + } + return storeExtensions.map((ext) => ExtensionListItem(installed: installedMap[ext.id], store: ext)).toList(); + } + + ExtensionListItem? findItem(String id) { + final installed = installedMap[id]; + final store = storeMap[id]; + if (installed == null && store == null) return null; + return ExtensionListItem(installed: installed, store: store); + } + + ExtensionsState copyWith({ + List? installedExtensions, + List? storeExtensions, + Map? updateFlags, + StorePagination? storePagination, + bool clearStorePagination = false, + String? storeQuery, + StoreExtensionSort? storeSort, + ExtensionListFilter? listFilter, + bool? loadingInstalled, + bool? loadingStore, + bool? loadingMoreStore, + Set? busyExtensionIds, + bool? devMode, + }) { + return ExtensionsState( + installedExtensions: installedExtensions ?? this.installedExtensions, + storeExtensions: storeExtensions ?? this.storeExtensions, + updateFlags: updateFlags ?? this.updateFlags, + storePagination: clearStorePagination ? null : storePagination ?? this.storePagination, + storeQuery: storeQuery ?? this.storeQuery, + storeSort: storeSort ?? this.storeSort, + listFilter: listFilter ?? this.listFilter, + loadingInstalled: loadingInstalled ?? this.loadingInstalled, + loadingStore: loadingStore ?? this.loadingStore, + loadingMoreStore: loadingMoreStore ?? this.loadingMoreStore, + busyExtensionIds: busyExtensionIds ?? this.busyExtensionIds, + devMode: devMode ?? this.devMode, + ); + } +} + +class ExtensionsController extends AsyncNotifier { + static const manualInstallBusyKey = '__manual_install__'; + int _devModeCount = 0; + + @override + Future build() async { + var next = const ExtensionsState(loadingInstalled: true, loadingStore: true); + state = AsyncValue.data(next); + final installed = await _loadInstalled(refreshUpdates: true, current: next); + next = state.value ?? installed; + await _refreshStore(current: next); + return state.value ?? next; + } + + Future loadInitialData() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(await build()); + } + + Future loadInstalled({bool refreshUpdates = false}) async { + await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true)); + } + + Future refreshStore() async { + await _refreshStore(current: _current.copyWith(loadingStore: true)); + } + + Future loadMoreStore() async { + final current = _current; + final pagination = current.storePagination; + if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return; + state = AsyncValue.data(current.copyWith(loadingMoreStore: true)); + try { + final page = await GopeedSiteApi.instance.getExtensions( + page: pagination.page + 1, + limit: pagination.limit, + sort: current.storeSort, + query: current.storeQuery, + ); + state = AsyncValue.data( + _current.copyWith( + storeExtensions: [..._current.storeExtensions, ...page.data], + storePagination: page.pagination, + loadingMoreStore: false, + ), + ); + } catch (_) { + state = AsyncValue.data(_current.copyWith(loadingMoreStore: false)); + rethrow; + } + } + + Future searchStore(String query) async { + state = AsyncValue.data(_current.copyWith(storeQuery: query.trim())); + await refreshStore(); + } + + Future changeSort(StoreExtensionSort sort) async { + state = AsyncValue.data(_current.copyWith(storeSort: sort)); + await refreshStore(); + } + + void changeFilter(ExtensionListFilter filter) { + state = AsyncValue.data(_current.copyWith(listFilter: filter)); + } + + bool canUpdateItem(ExtensionListItem item) { + final current = _current; + if (item.installed == null) return false; + if (current.listFilter == ExtensionListFilter.market && item.store != null) { + return _compareVersion(item.store!.version, item.installed!.version) > 0; + } + return current.updateFlags.containsKey(item.installed!.identity); + } + + Future installFromStore(StoreExtension extension) async { + await _runBusy(extension.id, () async { + final installUrl = (extension.directory ?? '').trim().isEmpty + ? extension.repoUrl + : '${extension.repoUrl}#${extension.directory!.trim()}'; + final installedId = await ref.read(gopeedServiceProvider).installExtension(InstallExtension(url: installUrl)); + await loadInstalled(refreshUpdates: false); + unawaited(checkUpdate()); + _bumpStoreInstallCount(installedId.isNotEmpty ? installedId : extension.id); + _reportInstallSafe(installedId.isNotEmpty ? installedId : extension.id); + }); + } + + Future installFromUrl(String url, {bool devInstall = false}) async { + await _runBusy(manualInstallBusyKey, () async { + final installedId = await ref + .read(gopeedServiceProvider) + .installExtension(InstallExtension(devMode: devInstall, url: url)); + await loadInstalled(refreshUpdates: false); + unawaited(checkUpdate()); + if (installedId.isNotEmpty) { + _bumpStoreInstallCount(installedId); + _reportInstallSafe(installedId); + } + }); + } + + Future toggleExtension(Extension extension, bool enabled) async { + await _runBusy(extension.identity, () async { + await ref.read(gopeedServiceProvider).switchExtension(extension.identity, SwitchExtension(status: enabled)); + await loadInstalled(refreshUpdates: false); + }); + } + + Future removeExtension(Extension extension) async { + await _runBusy(extension.identity, () async { + await ref.read(gopeedServiceProvider).deleteExtension(extension.identity); + await loadInstalled(refreshUpdates: false); + final flags = Map.of(_current.updateFlags)..remove(extension.identity); + state = AsyncValue.data(_current.copyWith(updateFlags: flags)); + }); + } + + Future upgradeExtension(Extension extension) async { + await _runBusy(extension.identity, () async { + await ref.read(gopeedServiceProvider).updateExtension(extension.identity); + await loadInstalled(refreshUpdates: false); + unawaited(checkUpdate()); + _bumpStoreInstallCount(extension.identity); + _reportInstallSafe(extension.identity); + }); + } + + Future saveExtensionSettings(Extension extension, Map settings) async { + await _runBusy(extension.identity, () async { + await ref + .read(gopeedServiceProvider) + .updateExtensionSettings(extension.identity, UpdateExtensionSettings(settings: settings)); + await loadInstalled(refreshUpdates: false); + }); + } + + Future checkUpdate() async { + final flags = {}; + for (final ext in _current.installedExtensions) { + try { + final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity); + if (resp.newVersion.isNotEmpty) { + flags[ext.identity] = resp.newVersion; + } + } catch (_) {} + } + state = AsyncValue.data(_current.copyWith(updateFlags: flags)); + } + + void tryOpenDevMode() { + if (_devModeCount == 0) { + Future.delayed(const Duration(seconds: 2), () { + if (_current.devMode) return; + _devModeCount = 0; + }); + } + _devModeCount++; + if (_devModeCount >= 5) { + state = AsyncValue.data(_current.copyWith(devMode: true)); + } + } + + ExtensionsState get _current => state.value ?? const ExtensionsState(); + + Future _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async { + state = AsyncValue.data(current.copyWith(loadingInstalled: true)); + final installed = await ref.read(gopeedServiceProvider).getExtensions(); + state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false)); + if (refreshUpdates) { + await checkUpdate(); + } + return _current; + } + + Future _refreshStore({required ExtensionsState current}) async { + state = AsyncValue.data(current.copyWith(loadingStore: true)); + final page = await GopeedSiteApi.instance.getExtensions( + page: 1, + limit: 20, + sort: _current.storeSort, + query: _current.storeQuery, + ); + state = AsyncValue.data( + _current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false), + ); + } + + Future _runBusy(String id, Future Function() action) async { + if (_current.busyExtensionIds.contains(id)) return; + state = AsyncValue.data(_current.copyWith(busyExtensionIds: {..._current.busyExtensionIds, id})); + try { + await action(); + } finally { + final busy = Set.of(_current.busyExtensionIds)..remove(id); + state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy)); + } + } + + void _bumpStoreInstallCount(String id) { + final current = _current; + final index = current.storeExtensions.indexWhere((e) => e.id == id); + if (index < 0) return; + final ext = current.storeExtensions[index]; + final updated = [...current.storeExtensions]; + updated[index] = StoreExtension( + id: ext.id, + repoFullName: ext.repoFullName, + repoUrl: ext.repoUrl, + directory: ext.directory, + commitSha: ext.commitSha, + name: ext.name, + author: ext.author, + title: ext.title, + description: ext.description, + icon: ext.icon, + version: ext.version, + homepage: ext.homepage, + readme: ext.readme, + installCount: ext.installCount + 1, + stars: ext.stars, + topics: ext.topics, + createdAt: ext.createdAt, + updatedAt: ext.updatedAt, + ); + state = AsyncValue.data(current.copyWith(storeExtensions: updated)); + } + + void _reportInstallSafe(String id) { + unawaited(() async { + try { + await GopeedSiteApi.instance.reportExtensionInstall(id); + } catch (_) {} + }()); + } + + static int _compareVersion(String a, String b) { + final aNums = _toVersionNumbers(a); + final bNums = _toVersionNumbers(b); + final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length; + for (var i = 0; i < maxLen; i++) { + final left = i < aNums.length ? aNums[i] : 0; + final right = i < bNums.length ? bNums[i] : 0; + if (left > right) return 1; + if (left < right) return -1; + } + return 0; + } + + static List _toVersionNumbers(String version) { + return version + .split(RegExp(r'[^0-9]+')) + .where((part) => part.isNotEmpty) + .map((part) => int.tryParse(part) ?? 0) + .toList(); + } +} diff --git a/ui/flutter/lib/features/extensions/application/pending_extension_install.dart b/ui/flutter/lib/features/extensions/application/pending_extension_install.dart new file mode 100644 index 00000000..7cab6c4e --- /dev/null +++ b/ui/flutter/lib/features/extensions/application/pending_extension_install.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/install_extension.dart'; + +final pendingExtensionInstallProvider = NotifierProvider( + PendingExtensionInstall.new, +); + +class PendingExtensionInstall extends Notifier { + @override + InstallExtension? build() => null; + + void set(InstallExtension? install) { + state = install; + } + + void clear() { + state = null; + } +} diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart new file mode 100644 index 00000000..e719fdfd --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/pages/extension_details_page.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/detail/app_detail_surface.dart'; +import '../../application/extensions_controller.dart'; +import '../widgets/extension_detail_view.dart'; + +class ExtensionDetailsPage extends ConsumerWidget { + const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem}); + + final String extensionId; + final ExtensionListItem? initialItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stateAsync = ref.watch(extensionsControllerProvider); + final item = stateAsync.value?.findItem(extensionId) ?? initialItem; + final palette = AppPalette.of(context); + + return AppDetailPage( + title: item?.title ?? context.l10n.extensions, + onBack: () => context.canPop() ? context.pop() : context.go('/extensions'), + child: item == null + ? Center( + child: stateAsync.isLoading + ? const shad.CircularProgressIndicator() + : Text(context.l10n.noExtensions, style: TextStyle(color: palette.textMuted, fontSize: 13)), + ) + : ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true), + ); + } +} diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart new file mode 100644 index 00000000..cc462018 --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -0,0 +1,1273 @@ +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../api/model/extension.dart' as api_extension; +import '../../../../api/model/store_extension.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../home/presentation/widgets/primary_rail.dart'; +import '../../application/extensions_controller.dart'; +import '../../application/pending_extension_install.dart'; +import '../widgets/extension_detail_view.dart'; +import '../widgets/extension_icon.dart'; + +const _extensionCardMinWidth = 280.0; +const _extensionGridSpacing = 10.0; + +int _extensionGridColumnCount(double width) { + return ((width + _extensionGridSpacing) / (_extensionCardMinWidth + _extensionGridSpacing)) + .floor() + .clamp(1, 12) + .toInt(); +} + +class ExtensionsPage extends ConsumerStatefulWidget { + const ExtensionsPage({super.key}); + + @override + ConsumerState createState() => _ExtensionsPageState(); +} + +class _ExtensionsPageState extends ConsumerState { + final _searchController = TextEditingController(); + final _installController = TextEditingController(); + final _listScrollController = ScrollController(); + final Map _settingControllers = {}; + ExtensionListItem? _detailItem; + api_extension.Extension? _settingsExtension; + shad.OverlayCompleter? _installPopover; + bool _requestingNextPage = false; + + @override + void initState() { + super.initState(); + _listScrollController.addListener(_loadNextPageIfNeeded); + } + + @override + void dispose() { + _installPopover?.remove(); + _searchController.dispose(); + _installController.dispose(); + _listScrollController + ..removeListener(_loadNextPageIfNeeded) + ..dispose(); + for (final controller in _settingControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + ref.listen(pendingExtensionInstallProvider, (previous, next) { + if (next == null) return; + ref.read(pendingExtensionInstallProvider.notifier).clear(); + unawaited( + _runAction( + () => ref.read(extensionsControllerProvider.notifier).installFromUrl(next.url, devInstall: next.devMode), + ), + ); + }); + final stateAsync = ref.watch(extensionsControllerProvider); + final body = Stack( + children: [ + ColoredBox( + color: palette.bg, + child: Row( + children: [ + if (MediaQuery.sizeOf(context).width >= Breakpoints.mobile) + const PrimaryRail(activeSection: RailSection.extensions), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: MediaQuery.sizeOf(context).width >= Breakpoints.mobile && AppWindowChrome.reservesHeaderInset + ? AppDesignTokens.windowHeaderHeight + : 0, + ), + child: stateAsync.when( + loading: () => _buildContent(const ExtensionsState(loadingInstalled: true, loadingStore: true)), + error: (error, _) => _ErrorState( + error: error, + onRetry: () => ref.read(extensionsControllerProvider.notifier).loadInitialData(), + ), + data: (state) { + if (state.listFilter == ExtensionListFilter.market && + state.storePagination?.hasNext == true && + !state.loadingMoreStore) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadNextPageIfNeeded()); + } + return _buildContent(state); + }, + ), + ), + ), + ], + ), + ), + if (_settingsExtension != null) + _ExtensionSettingsPanel( + extension: _settingsExtension!, + controllers: _settingControllers, + onClose: () => setState(() => _settingsExtension = null), + onSave: _saveExtensionSettings, + ), + ], + ); + + return shad.Scaffold( + backgroundColor: palette.bg, + child: isDesktop + ? body + : Column( + children: [ + Expanded(child: SafeArea(bottom: false, child: body)), + const PrimaryBottomNavigation(activeSection: RailSection.extensions), + ], + ), + ); + } + + Widget _buildContent(ExtensionsState state) { + return _Content( + state: state, + scrollController: _listScrollController, + searchController: _searchController, + onSearch: (query) => _runAction(() => ref.read(extensionsControllerProvider.notifier).searchStore(query)), + onSort: (sort) => _runAction(() => ref.read(extensionsControllerProvider.notifier).changeSort(sort)), + onFilter: ref.read(extensionsControllerProvider.notifier).changeFilter, + onOpenInstall: _openInstallPopover, + onDevelopExtension: _openExtensionDevelopmentDocs, + onInstallFolder: _installFromFolder, + onRefresh: () => _runAction(() => ref.read(extensionsControllerProvider.notifier).loadInitialData()), + onItemAction: _runAction, + onOpenDetails: _openExtensionDetails, + onOpenSettings: _openExtensionSettings, + detailItem: _detailItem == null ? null : state.findItem(_detailItem!.id) ?? _detailItem, + onCloseDetails: () => setState(() => _detailItem = null), + ); + } + + Future _installFromUrl() async { + final url = _installController.text.trim(); + if (url.isEmpty) return; + await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url)); + } + + Future _loadNextPageIfNeeded() async { + if (!mounted || _requestingNextPage || !_listScrollController.hasClients) return; + if (_listScrollController.position.extentAfter > 240) return; + final state = ref.read(extensionsControllerProvider).value; + if (state == null || + state.listFilter != ExtensionListFilter.market || + state.storePagination?.hasNext != true || + state.loadingMoreStore) { + return; + } + + _requestingNextPage = true; + try { + await _runAction(() => ref.read(extensionsControllerProvider.notifier).loadMoreStore()); + } finally { + _requestingNextPage = false; + } + } + + void _openInstallPopover(BuildContext anchorContext) { + ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); + if (_installPopover != null) return; + final overlay = const shad.PopoverOverlayHandler().show( + context: anchorContext, + alignment: Alignment.topRight, + anchorAlignment: Alignment.bottomRight, + offset: const Offset(0, 8), + modal: false, + consumeOutsideTaps: false, + builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl), + ); + _installPopover = overlay; + unawaited( + overlay.animationFuture.whenComplete(() { + if (identical(_installPopover, overlay)) _installPopover = null; + }), + ); + } + + Future _installFromFolder() async { + final folder = await FilePicker.platform.getDirectoryPath(); + if (folder == null || folder.isEmpty) return; + await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true)); + } + + Future _openExtensionDevelopmentDocs() async { + final opened = await launchUrl(Uri.parse('https://gopeed.com/docs/dev-extension')); + if (!opened && mounted) { + showAppToast(context, context.l10n.unableOpenExtensionDocs, type: AppToastType.error); + } + } + + void _openExtensionSettings(api_extension.Extension extension) { + for (final controller in _settingControllers.values) { + controller.dispose(); + } + _settingControllers + ..clear() + ..addEntries( + (extension.settings ?? const []).map( + (setting) => MapEntry(setting.name, TextEditingController(text: setting.value?.toString() ?? '')), + ), + ); + setState(() { + _detailItem = null; + _settingsExtension = extension; + }); + } + + void _openExtensionDetails(ExtensionListItem item) { + setState(() { + _settingsExtension = null; + _detailItem = item; + }); + } + + Future _saveExtensionSettings() async { + final extension = _settingsExtension; + if (extension == null) return; + final values = {}; + for (final setting in extension.settings ?? const []) { + final text = _settingControllers[setting.name]?.text.trim() ?? ''; + values[setting.name] = switch (setting.type) { + api_extension.SettingType.number => num.tryParse(text) ?? 0, + api_extension.SettingType.boolean => text == 'true' || text == '1' || text.toLowerCase() == 'yes', + api_extension.SettingType.string => text, + }; + } + await _runAction(() => ref.read(extensionsControllerProvider.notifier).saveExtensionSettings(extension, values)); + if (mounted) { + setState(() => _settingsExtension = null); + } + } + + Future _runAction(Future Function() action) async { + try { + await action(); + } catch (error) { + if (!mounted) return; + showAppToast(context, error.toString(), type: AppToastType.error); + } + } +} + +class _Content extends StatelessWidget { + const _Content({ + required this.state, + required this.scrollController, + required this.searchController, + required this.onSearch, + required this.onSort, + required this.onFilter, + required this.onOpenInstall, + required this.onDevelopExtension, + required this.onInstallFolder, + required this.onRefresh, + required this.onItemAction, + required this.onOpenDetails, + required this.onOpenSettings, + required this.detailItem, + required this.onCloseDetails, + }); + + final ExtensionsState state; + final ScrollController scrollController; + final TextEditingController searchController; + final ValueChanged onSearch; + final ValueChanged onSort; + final ValueChanged onFilter; + final ValueChanged onOpenInstall; + final VoidCallback onDevelopExtension; + final VoidCallback onInstallFolder; + final VoidCallback onRefresh; + final Future Function(Future Function() action) onItemAction; + final ValueChanged onOpenDetails; + final ValueChanged onOpenSettings; + final ExtensionListItem? detailItem; + final VoidCallback onCloseDetails; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + final horizontalPadding = isDesktop ? 32.0 : 16.0; + final toolbar = _Toolbar( + state: state, + searchController: searchController, + onSearch: onSearch, + onSort: onSort, + onRefresh: onRefresh, + onOpenInstall: onOpenInstall, + onDevelopExtension: onDevelopExtension, + onInstallFolder: onInstallFolder, + ); + return Column( + children: [ + if (isDesktop) + Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: SizedBox( + height: AppDesignTokens.contentHeaderHeight, + child: Align(alignment: Alignment.center, child: toolbar), + ), + ) + else + Padding(padding: EdgeInsets.fromLTRB(horizontalPadding, 18, horizontalPadding, 0), child: toolbar), + SizedBox(height: isDesktop ? 8 : 12), + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + Column( + children: [ + Padding( + key: const ValueKey('extensions-filter-bar'), + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12), + child: _FilterBar(state: state, onFilter: onFilter), + ), + Expanded( + child: CustomScrollView( + key: const ValueKey('extensions-list-scroll-view'), + controller: scrollController, + slivers: [ + if ((state.loadingInstalled || state.loadingStore) && state.displayItems.isEmpty) + SliverPadding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), + sliver: const _ExtensionSkeletonGrid(key: ValueKey('extensions-initial-skeleton')), + ) + else if (state.displayItems.isEmpty) + SliverFillRemaining( + child: Center( + child: Text( + context.l10n.noExtensions, + style: TextStyle(color: palette.textSecondary, fontSize: 14), + ), + ), + ) + else + SliverPadding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final width = constraints.crossAxisExtent; + final crossAxisCount = _extensionGridColumnCount(width); + return SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) => _ExtensionCard( + item: state.displayItems[index], + busy: state.busyExtensionIds.contains(state.displayItems[index].id), + canUpdate: _canUpdate(state, state.displayItems[index]), + onAction: onItemAction, + onOpenDetails: onOpenDetails, + onOpenSettings: onOpenSettings, + ), + childCount: state.displayItems.length, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: _extensionGridSpacing, + crossAxisSpacing: _extensionGridSpacing, + mainAxisExtent: 178, + ), + ); + }, + ), + ), + if (state.listFilter == ExtensionListFilter.market && state.loadingMoreStore) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24, top: 4), + child: Center( + child: const SizedBox.square(dimension: 18, child: shad.CircularProgressIndicator()), + ), + ), + ), + if (state.listFilter == ExtensionListFilter.market && + state.displayItems.isNotEmpty && + state.storePagination != null && + !state.storePagination!.hasNext && + !state.loadingMoreStore) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24, top: 4), + child: Center( + child: Text( + context.l10n.extensionNoMore, + key: const ValueKey('extensions-no-more-indicator'), + style: TextStyle(color: palette.textMuted, fontSize: 12), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ExtensionDetailDrawer(item: detailItem, onClose: onCloseDetails), + ], + ), + ), + ], + ); + } + + bool _canUpdate(ExtensionsState state, ExtensionListItem item) { + if (item.installed == null) return false; + if (state.listFilter == ExtensionListFilter.market && item.store != null) { + return _compareVersion(item.store!.version, item.installed!.version) > 0; + } + return state.updateFlags.containsKey(item.installed!.identity); + } + + int _compareVersion(String a, String b) { + final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0); + final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0); + final left = aNums.toList(); + final right = bNums.toList(); + final maxLen = left.length > right.length ? left.length : right.length; + for (var i = 0; i < maxLen; i++) { + final l = i < left.length ? left[i] : 0; + final r = i < right.length ? right[i] : 0; + if (l != r) return l.compareTo(r); + } + return 0; + } +} + +class _ExtensionSkeletonGrid extends StatefulWidget { + const _ExtensionSkeletonGrid({super.key}); + + @override + State<_ExtensionSkeletonGrid> createState() => _ExtensionSkeletonGridState(); +} + +class _ExtensionSkeletonGridState extends State<_ExtensionSkeletonGrid> with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return SliverLayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = _extensionGridColumnCount(constraints.crossAxisExtent); + final availableHeight = constraints.remainingPaintExtent.isFinite ? constraints.remainingPaintExtent : 600; + final rowCount = ((availableHeight + _extensionGridSpacing) / (178 + _extensionGridSpacing)) + .ceil() + .clamp(1, 8) + .toInt(); + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final color = palette.surfaceSoft.withValues(alpha: 0.55 + _controller.value * 0.35); + return SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) => + _ExtensionSkeletonCard(key: ValueKey('extension-skeleton-card-$index'), color: color), + childCount: crossAxisCount * rowCount, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: _extensionGridSpacing, + crossAxisSpacing: _extensionGridSpacing, + mainAxisExtent: 178, + ), + ); + }, + ); + }, + ); + } +} + +class _ExtensionSkeletonCard extends StatelessWidget { + const _ExtensionSkeletonCard({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return ExcludeSemantics( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _ExtensionSkeletonBlock(width: 40, height: 40, color: color, radius: 6), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + widthFactor: 0.58, + alignment: Alignment.centerLeft, + child: _ExtensionSkeletonBlock(width: double.infinity, height: 13, color: color), + ), + const SizedBox(height: 7), + FractionallySizedBox( + widthFactor: 0.38, + alignment: Alignment.centerLeft, + child: _ExtensionSkeletonBlock(width: double.infinity, height: 9, color: color), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + FractionallySizedBox( + widthFactor: 0.92, + alignment: Alignment.centerLeft, + child: _ExtensionSkeletonBlock(width: double.infinity, height: 9, color: color), + ), + const SizedBox(height: 7), + FractionallySizedBox( + widthFactor: 0.7, + alignment: Alignment.centerLeft, + child: _ExtensionSkeletonBlock(width: double.infinity, height: 9, color: color), + ), + const Spacer(), + Row( + children: [ + _ExtensionSkeletonBlock(width: 34, height: 9, color: color), + const SizedBox(width: 12), + _ExtensionSkeletonBlock(width: 42, height: 9, color: color), + const Spacer(), + _ExtensionSkeletonBlock(width: 24, height: 24, color: color, radius: 12), + ], + ), + ], + ), + ), + ); + } +} + +class _ExtensionSkeletonBlock extends StatelessWidget { + const _ExtensionSkeletonBlock({required this.width, required this.height, required this.color, this.radius = 4}); + + final double width; + final double height; + final Color color; + final double radius; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(radius)), + ); + } +} + +class _Toolbar extends StatelessWidget { + const _Toolbar({ + required this.state, + required this.searchController, + required this.onSearch, + required this.onSort, + required this.onRefresh, + required this.onOpenInstall, + required this.onDevelopExtension, + required this.onInstallFolder, + }); + + final ExtensionsState state; + final TextEditingController searchController; + final ValueChanged onSearch; + final ValueChanged onSort; + final VoidCallback onRefresh; + final ValueChanged onOpenInstall; + final VoidCallback onDevelopExtension; + final VoidCallback onInstallFolder; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey); + final search = shad.TextField( + key: const ValueKey('extension-search-input'), + controller: searchController, + placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)), + features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))], + onSubmitted: onSearch, + ); + final sorting = Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Segmented( + key: const ValueKey('extension-sort-control'), + compact: true, + value: state.storeSort, + values: [ + (StoreExtensionSort.stars, context.l10n.extensionSortStars), + (StoreExtensionSort.installs, context.l10n.extensionSortInstalls), + (StoreExtensionSort.updated, context.l10n.extensionSortUpdated), + ], + onChanged: onSort, + ), + const SizedBox(width: 8), + _OutlineToolbarIconButton( + key: const ValueKey('refresh-extensions-button'), + tooltip: context.l10n.refresh, + icon: Icons.refresh, + onPressed: onRefresh, + ), + ], + ); + final installActions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + _OutlineToolbarIconButton( + key: const ValueKey('develop-extension-button'), + tooltip: context.l10n.extensionDeveloperGuide, + icon: Icons.menu_book_outlined, + onPressed: onDevelopExtension, + ), + const SizedBox(width: 8), + if (state.devMode) ...[ + _OutlineToolbarIconButton( + key: const ValueKey('load-local-extension-button'), + tooltip: context.l10n.extensionLoadLocal, + icon: Icons.folder_open_outlined, + loading: manualInstallBusy, + onPressed: onInstallFolder, + ), + const SizedBox(width: 8), + ], + Builder( + builder: (buttonContext) => _OutlineToolbarIconButton( + key: const ValueKey('install-extension-button'), + tooltip: context.l10n.extensionInstallFromUrl, + icon: Icons.add_link, + loading: manualInstallBusy, + onPressed: () => onOpenInstall(buttonContext), + ), + ), + ], + ); + if (MediaQuery.sizeOf(context).width >= Breakpoints.mobile) { + return Row( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + key: const ValueKey('extension-search-field-container'), + constraints: const BoxConstraints(maxWidth: 240), + child: search, + ), + ), + ), + const SizedBox(width: 10), + Flexible( + flex: 2, + child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: sorting), + ), + ], + ), + ), + const SizedBox(width: 10), + installActions, + ], + ); + } + return Column( + children: [ + SizedBox(width: double.infinity, child: search), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: sorting), + ), + ), + const SizedBox(width: 10), + installActions, + ], + ), + ], + ); + } +} + +class _FilterBar extends StatelessWidget { + const _FilterBar({required this.state, required this.onFilter}); + + final ExtensionsState state; + final ValueChanged onFilter; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: _Segmented( + value: state.listFilter, + values: [ + (ExtensionListFilter.market, context.l10n.extensionFilterMarket), + (ExtensionListFilter.installed, context.l10n.extensionFilterInstalled), + ], + onChanged: onFilter, + ), + ); + } +} + +class _OutlineToolbarIconButton extends StatelessWidget { + const _OutlineToolbarIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.loading = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) { + return AppTooltip( + message: tooltip, + child: SizedBox.square( + dimension: 32, + child: shad.IconButton.outline( + size: shad.ButtonSize.xSmall, + onPressed: loading ? null : onPressed, + icon: loading + ? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator()) + : Icon(icon, size: 17), + ), + ), + ); + } +} + +class _InstallPopover extends StatefulWidget { + const _InstallPopover({required this.controller, required this.onInstallUrl}); + + final TextEditingController controller; + final Future Function() onInstallUrl; + + @override + State<_InstallPopover> createState() => _InstallPopoverState(); +} + +class _InstallPopoverState extends State<_InstallPopover> { + bool _installing = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + key: const ValueKey('extension-install-popover'), + width: 328, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + boxShadow: [BoxShadow(color: const Color(0x26000000), blurRadius: 18, offset: const Offset(0, 6))], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.extensionInstallFromUrl, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + shad.TextField( + key: const ValueKey('extension-install-url-input'), + controller: widget.controller, + autofocus: true, + placeholder: const Text('https://github.com/author/repo'), + onSubmitted: (_) => _install(), + features: [ + shad.InputFeature.trailing( + SizedBox.square( + dimension: 28, + child: shad.IconButton.outline( + size: shad.ButtonSize.xSmall, + onPressed: _installing ? null : _install, + icon: _installing + ? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator()) + : const Icon(Icons.download_outlined, size: 16), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Future _install() async { + if (_installing || widget.controller.text.trim().isEmpty) return; + setState(() => _installing = true); + await widget.onInstallUrl(); + if (!mounted) return; + setState(() => _installing = false); + await shad.closeOverlay(context); + } +} + +class _Segmented extends StatelessWidget { + const _Segmented({ + super.key, + required this.value, + required this.values, + required this.onChanged, + this.compact = false, + }); + + final T value; + final List<(T, String)> values; + final ValueChanged onChanged; + final bool compact; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + height: compact ? 32 : null, + padding: EdgeInsets.all(compact ? 2 : 3), + decoration: BoxDecoration( + color: palette.surfaceSoft, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: values.map((entry) { + final selected = entry.$1 == value; + return GestureDetector( + onTap: () => onChanged(entry.$1), + child: Container( + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10, vertical: compact ? 4 : 7), + decoration: BoxDecoration( + color: selected ? palette.cardBg : null, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + entry.$2, + style: TextStyle( + color: selected ? palette.textPrimary : palette.textSecondary, + fontSize: compact ? 11 : 12, + fontWeight: selected ? (compact ? FontWeight.w600 : FontWeight.w700) : FontWeight.w500, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _ExtensionCard extends ConsumerWidget { + const _ExtensionCard({ + required this.item, + required this.busy, + required this.canUpdate, + required this.onAction, + required this.onOpenDetails, + required this.onOpenSettings, + }); + + final ExtensionListItem item; + final bool busy; + final bool canUpdate; + final Future Function(Future Function() action) onAction; + final ValueChanged onOpenDetails; + final ValueChanged onOpenSettings; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final palette = AppPalette.of(context); + final installed = item.installed; + final card = Container( + key: ValueKey('extension-card-${item.id}'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ExtensionIcon(item: item, size: 40, borderRadius: BorderRadius.circular(6), fallbackFit: BoxFit.cover), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 2), + Text( + '${item.author} · v${item.version}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textMuted, fontSize: 11), + ), + ], + ), + ), + if (installed != null) + shad.Switch( + value: !installed.disabled, + onChanged: busy + ? null + : (enabled) => onAction( + () => ref.read(extensionsControllerProvider.notifier).toggleExtension(installed, enabled), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + item.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.25), + ), + const Spacer(), + Row( + children: [ + if (item.store != null) ...[ + Icon(Icons.star_rounded, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text('${item.stars}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(width: 10), + Icon(Icons.download_outlined, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text('${item.installCount}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), + ], + const Spacer(), + if (canUpdate && installed != null) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => + onAction(() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed)), + child: const Icon(Icons.refresh), + ), + if (installed == null && item.store != null) + shad.GhostButton( + key: ValueKey('install-store-extension-${item.store!.id}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => onAction( + () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), + ), + child: busy + ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) + : const Icon(Icons.download), + ), + if ((item.homepage ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), + child: const Icon(Icons.home_outlined), + ), + if ((item.repoUrl ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), + child: const Icon(Icons.code), + ), + if (installed?.settings?.isNotEmpty == true) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy ? null : () => onOpenSettings(installed!), + child: const Icon(Icons.settings_outlined), + ), + if (installed != null) + shad.GhostButton( + key: ValueKey('remove-extension-${installed.identity}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () async { + final confirmed = await _showRemoveExtensionDialog(context, installed.title); + if (!confirmed || !context.mounted) return; + await onAction( + () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), + ); + }, + child: const Icon(Icons.delete_outline), + ), + ], + ), + ], + ), + ); + if (item.store == null) return card; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: ValueKey('open-extension-details-${item.id}'), + behavior: HitTestBehavior.opaque, + onTap: () { + if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { + context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item); + return; + } + onOpenDetails(item); + }, + child: card, + ), + ); + } +} + +Future _showRemoveExtensionDialog(BuildContext context, String extensionName) async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => shad.AlertDialog( + key: const ValueKey('remove-extension-dialog'), + title: Text(dialogContext.l10n.removeExtensionTitle), + content: Text(dialogContext.l10n.removeExtensionConfirm(extensionName)), + actions: [ + shad.SecondaryButton( + key: const ValueKey('cancel-remove-extension-button'), + onPressed: () => shad.closeOverlay(dialogContext, false), + child: Text(dialogContext.l10n.cancel), + ), + shad.DestructiveButton( + key: const ValueKey('confirm-remove-extension-button'), + onPressed: () => shad.closeOverlay(dialogContext, true), + child: Text(dialogContext.l10n.uninstall), + ), + ], + ), + ); + return await overlay.future ?? false; +} + +class _ExtensionSettingsPanel extends StatelessWidget { + const _ExtensionSettingsPanel({ + required this.extension, + required this.controllers, + required this.onClose, + required this.onSave, + }); + + final api_extension.Extension extension; + final Map controllers; + final VoidCallback onClose; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final settings = extension.settings ?? const []; + return Positioned.fill( + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: onClose, + child: ColoredBox(color: const Color(0x66000000), child: const SizedBox.expand()), + ), + ), + Container( + width: MediaQuery.sizeOf(context).width < Breakpoints.mobile ? MediaQuery.sizeOf(context).width : 420, + decoration: BoxDecoration( + color: palette.bg, + border: Border(left: BorderSide(color: palette.border)), + ), + child: SafeArea( + child: Column( + children: [ + SizedBox( + height: 56, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Row( + children: [ + Expanded( + child: Text( + extension.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 16, fontWeight: FontWeight.w800), + ), + ), + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: onClose, + child: const Icon(Icons.close), + ), + ], + ), + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(18), + itemCount: settings.length, + separatorBuilder: (_, _) => const SizedBox(height: 14), + itemBuilder: (context, index) { + final setting = settings[index]; + return _ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); + }, + ), + ), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.border)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + shad.SecondaryButton(onPressed: onClose, child: Text(context.l10n.cancel)), + const SizedBox(width: 10), + AppPrimaryButton(onPressed: onSave, child: Text(context.l10n.save)), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _ExtensionSettingField extends StatelessWidget { + const _ExtensionSettingField({required this.setting, required this.controller}); + + final api_extension.Setting setting; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + setting.title, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + if (setting.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), + ], + const SizedBox(height: 8), + if (setting.type == api_extension.SettingType.boolean) + shad.TextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint)) + else + shad.TextField( + controller: controller, + keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text, + ), + ], + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.extension_off_outlined, size: 32, color: palette.error), + const SizedBox(height: 12), + Text( + context.l10n.unableLoadExtensions, + style: TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + error.toString(), + textAlign: TextAlign.center, + style: TextStyle(color: palette.textSecondary), + ), + const SizedBox(height: 16), + shad.SecondaryButton(onPressed: onRetry, child: Text(context.l10n.retry)), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart new file mode 100644 index 00000000..886be8ef --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -0,0 +1,467 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as path; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../api/model/store_extension.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/detail/app_detail_surface.dart'; +import '../../../../util/util.dart'; +import '../../application/extensions_controller.dart'; +import 'extension_icon.dart'; + +class ExtensionDetailDrawer extends StatelessWidget { + const ExtensionDetailDrawer({super.key, required this.item, required this.onClose}); + + final ExtensionListItem? item; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return AppDetailDrawer( + open: item != null, + title: item?.title ?? '', + onClose: onClose, + drawerKey: const ValueKey('extension-details-drawer'), + child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!), + ); + } +} + +class ExtensionDetailView extends ConsumerWidget { + const ExtensionDetailView({super.key, required this.item, this.mobile = false}); + + final ExtensionListItem item; + final bool mobile; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final palette = AppPalette.of(context); + final state = ref.watch(extensionsControllerProvider).value; + final current = state?.findItem(item.id) ?? item; + final installed = current.installed; + final store = current.store; + final busy = state?.busyExtensionIds.contains(current.id) ?? false; + final canUpdate = ref.read(extensionsControllerProvider.notifier).canUpdateItem(current); + final horizontalPadding = mobile ? AppDesignTokens.space16 : AppDesignTokens.space32; + + return ListView( + key: const ValueKey('extension-details-content'), + padding: EdgeInsets.fromLTRB(horizontalPadding, mobile ? 24 : 28, horizontalPadding, mobile ? 40 : 48), + children: [ + Row( + key: const ValueKey('extension-details-hero'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExtensionIcon( + item: current, + size: mobile ? 60 : 68, + borderRadius: BorderRadius.circular(14), + fallbackPadding: EdgeInsets.all((mobile ? 60 : 68) * 0.16), + fallbackBackgroundColor: palette.surfaceSoft, + ), + SizedBox(width: mobile ? 16 : 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + current.author, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + const SizedBox(height: 7), + Text( + current.description, + style: TextStyle(color: palette.textSecondary, fontSize: mobile ? 14 : 13.5, height: 1.5), + ), + const SizedBox(height: 10), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 9, + runSpacing: 5, + children: [ + Text( + 'v${current.version}', + style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600), + ), + if (store != null) _ExtensionStats(store: store), + if (installed != null && canUpdate) + _ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true), + ], + ), + ], + ), + ), + ], + ), + SizedBox(height: mobile ? 24 : 28), + Wrap( + key: const ValueKey('extension-details-actions'), + spacing: 4, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (installed == null && store != null) + AppLoadingButton( + key: const ValueKey('extension-details-install'), + loading: busy, + variant: AppLoadingButtonVariant.primary, + icon: const Icon(Icons.download_outlined, size: 16), + onPressed: () => + _runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)), + child: Text(context.l10n.extensionInstall), + ), + if (installed != null && canUpdate) + AppLoadingButton( + key: const ValueKey('extension-details-update'), + loading: busy, + variant: AppLoadingButtonVariant.primary, + icon: const Icon(Icons.refresh, size: 16), + onPressed: () => _runAction( + context, + () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), + successMessage: context.l10n.extensionUpdateSuccess, + ), + child: Text(context.l10n.newVersionUpdate), + ), + if (installed != null && !canUpdate) + AppLoadingButton( + key: const ValueKey('extension-details-installed'), + loading: false, + variant: AppLoadingButtonVariant.primary, + icon: const Icon(Icons.check_circle_outline, size: 16), + onPressed: null, + child: Text(context.l10n.extensionInstalled), + ), + if ((current.homepage ?? '').isNotEmpty) + shad.SecondaryButton( + key: const ValueKey('extension-details-homepage'), + onPressed: () => unawaited(_openUrl(current.homepage!)), + child: _LinkButtonContent(icon: Icons.home_outlined, label: context.l10n.homepage), + ), + if ((current.repoUrl ?? '').isNotEmpty) + shad.SecondaryButton( + key: const ValueKey('extension-details-github'), + onPressed: () => unawaited(_openUrl(current.repoUrl!)), + child: const _LinkButtonContent(icon: Icons.code, label: 'GitHub'), + ), + ], + ), + if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)], + ], + ); + } + + Future _runAction(BuildContext context, Future Function() action, {String? successMessage}) async { + try { + await action(); + if (context.mounted && successMessage != null) { + showAppToast(context, successMessage, type: AppToastType.success); + } + } catch (error) { + if (context.mounted) showAppToast(context, error.toString(), type: AppToastType.error); + } + } +} + +class _ExtensionReadme extends StatefulWidget { + const _ExtensionReadme({required this.item, required this.mobile}); + + final ExtensionListItem item; + final bool mobile; + + @override + State<_ExtensionReadme> createState() => _ExtensionReadmeState(); +} + +class _ExtensionReadmeState extends State<_ExtensionReadme> { + late Future<_ReadmeInfo> _readme; + + @override + void initState() { + super.initState(); + _readme = _loadReadme(widget.item); + } + + @override + void didUpdateWidget(covariant _ExtensionReadme oldWidget) { + super.didUpdateWidget(oldWidget); + final previous = oldWidget.item.installed; + final current = widget.item.installed; + if (previous?.identity != current?.identity || + previous?.devMode != current?.devMode || + previous?.devPath != current?.devPath || + oldWidget.item.store?.readme != widget.item.store?.readme) { + _readme = _loadReadme(widget.item); + } + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return FutureBuilder<_ReadmeInfo>( + future: _readme, + builder: (context, snapshot) { + final info = snapshot.data; + if (info == null) { + return const SizedBox.shrink(); + } + if (info.content.trim().isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + key: const ValueKey('extension-details-readme'), + padding: EdgeInsets.only(top: widget.mobile ? 32 : 36), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'README', + style: TextStyle( + color: palette.textMuted, + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + MarkdownBody( + data: info.content, + selectable: true, + styleSheet: _markdownStyle(palette, mobile: widget.mobile), + onTapLink: (_, href, _) { + final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false); + if (resolved != null) unawaited(_openUrl(resolved)); + }, + imageBuilder: (uri, title, alt) { + final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true); + if (resolved == null) return const SizedBox.shrink(); + final image = resolved.startsWith('file:') + ? Image.file( + File(Uri.parse(resolved).toFilePath()), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ) + : Image.network( + resolved, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image), + ); + }, + ), + ], + ), + ); + }, + ); + } +} + +class _ExtensionStatus extends StatelessWidget { + const _ExtensionStatus({required this.label, required this.emphasized}); + + final String label; + final bool emphasized; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final dotColor = emphasized ? palette.brand : palette.success; + final textColor = emphasized ? palette.textPrimary : palette.success; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 5, + height: 5, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600), + ), + ], + ); + } +} + +class _LinkButtonContent extends StatelessWidget { + const _LinkButtonContent({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: palette.textMuted), + const SizedBox(width: 7), + Text(label), + ], + ); + } +} + +class _ExtensionStats extends StatelessWidget { + const _ExtensionStats({required this.store}); + + final StoreExtension store; + + @override + Widget build(BuildContext context) { + return Row( + key: const ValueKey('extension-details-stats'), + mainAxisSize: MainAxisSize.min, + children: [ + _MetadataItem(icon: Icons.star_rounded, value: store.stars.toString()), + const SizedBox(width: 12), + _MetadataItem(icon: Icons.download_outlined, value: store.installCount.toString()), + ], + ); + } +} + +class _MetadataItem extends StatelessWidget { + const _MetadataItem({required this.icon, required this.value}); + + final IconData icon; + final String value; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: palette.textMuted), + const SizedBox(width: 6), + Text( + value, + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ); + } +} + +MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) { + final paragraph = TextStyle(color: palette.textSecondary, fontSize: mobile ? 14 : 13.5, height: 1.62); + final heading = TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w800, height: 1.25); + return MarkdownStyleSheet( + a: paragraph.copyWith(color: palette.brand, decoration: TextDecoration.underline), + p: paragraph, + code: paragraph.copyWith(fontFamily: 'monospace', fontSize: 12, backgroundColor: palette.surfaceSoft), + h1: heading.copyWith(fontSize: mobile ? 21 : 20), + h2: heading.copyWith(fontSize: mobile ? 18 : 17), + h3: heading.copyWith(fontSize: mobile ? 16 : 15), + h4: heading.copyWith(fontSize: 14), + h5: heading.copyWith(fontSize: 13), + h6: heading.copyWith(fontSize: 12), + em: paragraph.copyWith(fontStyle: FontStyle.italic), + strong: paragraph.copyWith(fontWeight: FontWeight.w800, color: palette.textPrimary), + blockSpacing: 12, + listIndent: 22, + listBullet: paragraph, + blockquote: paragraph, + blockquotePadding: const EdgeInsets.fromLTRB(12, 8, 10, 8), + blockquoteDecoration: BoxDecoration( + color: palette.surfaceSoft, + border: Border(left: BorderSide(color: palette.brand, width: 3)), + ), + codeblockPadding: const EdgeInsets.all(12), + codeblockDecoration: BoxDecoration( + color: palette.surfaceSoft, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + horizontalRuleDecoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.border)), + ), + tableHead: paragraph.copyWith(color: palette.textPrimary, fontWeight: FontWeight.w800), + tableBody: paragraph, + tableBorder: TableBorder.all(color: palette.border), + tableCellsPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + ); +} + +Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async { + final installed = item.installed; + final remote = item.store?.readme ?? ''; + if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote); + + try { + final rootDirectory = installed.devMode + ? installed.devPath + : path.join(Util.getStorageDir(), 'extensions', installed.identity); + for (final name in const ['README.md', 'readme.md', 'README.MD']) { + final filePath = path.join(rootDirectory, name); + final file = File(filePath); + if (file.existsSync()) { + return _ReadmeInfo(content: file.readAsStringSync(), localPath: filePath); + } + } + } catch (_) { + // Fall back to the store copy when local storage is unavailable or unreadable. + } + return _ReadmeInfo(content: remote); +} + +String? _resolveReadmeUrl(ExtensionListItem item, String? raw, {required _ReadmeInfo info, required bool forImage}) { + final store = item.store; + final value = raw?.trim() ?? ''; + if (value.isEmpty) return null; + final parsed = Uri.tryParse(value); + if (parsed != null && parsed.hasScheme) return parsed.toString(); + + if (forImage && info.localPath != null) { + final clean = value.split('#').first; + return Uri.file(path.normalize(path.join(path.dirname(info.localPath!), clean))).toString(); + } + if (store == null) return null; + + final ref = store.commitSha?.isNotEmpty == true ? store.commitSha! : 'HEAD'; + final directory = (store.directory ?? '').split('/').where((segment) => segment.isNotEmpty).join('/'); + final suffix = directory.isEmpty ? '' : '$directory/'; + final base = forImage + ? Uri.https('raw.githubusercontent.com', '/${store.repoFullName}/$ref/$suffix') + : Uri.https('github.com', '/${store.repoFullName}/blob/$ref/$suffix'); + return base.resolve(value).toString(); +} + +class _ReadmeInfo { + const _ReadmeInfo({required this.content, this.localPath}); + + final String content; + final String? localPath; +} + +Future _openUrl(String value) async { + final uri = Uri.tryParse(value); + if (uri == null) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); +} diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_icon.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_icon.dart new file mode 100644 index 00000000..3eac739c --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_icon.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as path; + +import '../../../../api/model/extension.dart'; +import '../../../../util/util.dart'; +import '../../application/extensions_controller.dart'; + +enum ExtensionIconSourceKind { asset, file, network } + +@immutable +class ExtensionIconSource { + const ExtensionIconSource(this.kind, this.location); + + final ExtensionIconSourceKind kind; + final String location; +} + +@visibleForTesting +ExtensionIconSource resolveExtensionIconSource(ExtensionListItem item, {bool? web, String? storageDirectory}) { + const fallbackAsset = 'assets/extension/default_icon.png'; + final installed = item.installed; + if (installed != null) { + final icon = installed.icon.trim(); + if (icon.isEmpty) { + return const ExtensionIconSource(ExtensionIconSourceKind.asset, fallbackAsset); + } + + if (web ?? kIsWeb) { + return ExtensionIconSource(ExtensionIconSourceKind.network, _installedExtensionIconUrl(installed, icon)); + } + + final rootDirectory = installed.devMode + ? installed.devPath + : path.join(storageDirectory ?? Util.getStorageDir(), 'extensions', installed.identity); + return ExtensionIconSource(ExtensionIconSourceKind.file, path.join(rootDirectory, icon)); + } + + final remoteIcon = item.store?.icon?.trim() ?? ''; + if (remoteIcon.isNotEmpty) { + return ExtensionIconSource(ExtensionIconSourceKind.network, remoteIcon); + } + return const ExtensionIconSource(ExtensionIconSourceKind.asset, fallbackAsset); +} + +String _installedExtensionIconUrl(Extension extension, String icon) { + final iconSegments = icon + .replaceAll('\\', '/') + .split('/') + .where((segment) => segment.isNotEmpty && segment != '.') + .toList(); + return Uri(pathSegments: ['', 'fs', 'extensions', extension.identity, ...iconSegments]).toString(); +} + +class ExtensionIcon extends StatelessWidget { + const ExtensionIcon({ + super.key, + required this.item, + required this.size, + required this.borderRadius, + this.fallbackPadding = EdgeInsets.zero, + this.fallbackBackgroundColor, + this.fallbackFit = BoxFit.contain, + }); + + final ExtensionListItem item; + final double size; + final BorderRadius borderRadius; + final EdgeInsetsGeometry fallbackPadding; + final Color? fallbackBackgroundColor; + final BoxFit fallbackFit; + + @override + Widget build(BuildContext context) { + final fallback = Container( + width: size, + height: size, + padding: fallbackPadding, + color: fallbackBackgroundColor, + child: Image.asset('assets/extension/default_icon.png', fit: fallbackFit), + ); + final source = resolveExtensionIconSource(item); + final image = switch (source.kind) { + ExtensionIconSourceKind.asset => fallback, + ExtensionIconSourceKind.file => Image.file( + File(source.location), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + ), + ExtensionIconSourceKind.network => Image.network( + source.location, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + ), + }; + return ClipRRect(borderRadius: borderRadius, child: image); + } +} diff --git a/ui/flutter/lib/features/home/presentation/pages/home_page.dart b/ui/flutter/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 00000000..5d356b3b --- /dev/null +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -0,0 +1,974 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../app/application/app_runtime_controller.dart'; +import '../../../../core/capabilities/app_capabilities.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/utils/file_explorer.dart'; +import '../../../../core/window/app_window_launcher.dart'; +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/responsive_navigation_layout.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../util/util.dart'; +import '../../../tasks/application/tasks_controller.dart'; +import '../../../tasks/application/pending_update_task.dart'; +import '../../../tasks/application/task_batch_selection_controller.dart'; +import '../../../tasks/domain/task_record.dart'; +import '../../../tasks/presentation/widgets/speed_monitor_card.dart'; +import '../../../tasks/presentation/widgets/task_batch_selection_builder.dart'; +import '../../../tasks/presentation/widgets/task_card/task_card.dart'; +import '../../../tasks/presentation/widgets/task_drawer.dart'; +import '../../../tasks/presentation/widgets/task_context_menu.dart'; +import '../../../tasks/presentation/widgets/task_delete_dialog.dart'; +import '../../../tasks/presentation/widgets/task_empty_state.dart'; +import '../../../tasks/presentation/widgets/task_file_browser_dialog.dart'; +import '../../../tasks/presentation/widgets/task_update_url_dialog.dart'; +import '../widgets/primary_rail.dart'; +import '../widgets/tasks_top_bar.dart'; + +enum _TaskFilter { downloading, completed, failed } + +class _BatchActionAvailability { + const _BatchActionAvailability({required this.canPause, required this.canResume}); + + final bool canPause; + final bool canResume; +} + +_BatchActionAvailability _batchActionAvailability(Iterable tasks, TaskBatchSelectionController selection) { + var canPause = false; + var canResume = false; + for (final task in tasks) { + if (!selection.contains(task.id)) continue; + canPause = canPause || task.status == TaskStatus.downloading; + canResume = canResume || task.status == TaskStatus.paused || task.status == TaskStatus.failed; + if (canPause && canResume) break; + } + return _BatchActionAvailability(canPause: canPause, canResume: canResume); +} + +class HomePage extends ConsumerStatefulWidget { + const HomePage({super.key}); + + @override + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + _TaskFilter _activeFilter = _TaskFilter.downloading; + TaskRecord? _selectedTask; + bool _batchMode = false; + final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController(); + + @override + void initState() { + super.initState(); + _searchController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _searchController.dispose(); + _batchSelection.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final tasksAsync = ref.watch(tasksControllerProvider); + final tasksState = tasksAsync.value; + final tasks = tasksState?.tasks ?? const []; + final filteredTasks = _filteredTasks(tasks); + final emptyStateMessage = _searchController.text.trim().isEmpty + ? context.l10n.emptyTaskList + : context.l10n.noMatchingTasks; + final selectedTask = _latestSelectedTask(tasks); + final visibleTaskIds = filteredTasks.map((task) => task.id).toList(growable: false); + final pendingUpdateTask = ref.watch(pendingUpdateTaskProvider); + final navigationItems = _taskNavigationItems(tasks); + final desktopBody = shad.Scaffold( + backgroundColor: palette.bg, + child: Row( + children: [ + const PrimaryRail(), + Expanded( + child: ResponsiveNavigationLayout<_TaskFilter>( + desktopTitle: context.l10n.task.toUpperCase(), + items: navigationItems, + selectedValue: _activeFilter, + onSelected: _setActiveFilter, + desktopFooter: SpeedMonitorCard( + downloadBytesPerSecond: tasksState?.downloadSpeedBytesPerSecond ?? 0, + uploadBytesPerSecond: tasksState?.uploadSpeedBytesPerSecond ?? 0, + ), + child: Padding( + padding: EdgeInsets.only( + top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0, + ), + child: Column( + children: [ + ListenableBuilder( + listenable: _batchSelection, + builder: (context, _) { + final availability = _batchActionAvailability(tasks, _batchSelection); + return TasksTopBar( + searchController: _searchController, + onAddTask: _handleAddTask, + batchMode: _batchMode, + selectedBatchCount: _batchSelection.count, + canPauseSelected: availability.canPause, + canResumeSelected: availability.canResume, + onToggleBatchMode: _toggleBatchMode, + onPauseSelected: _pauseSelectedTasks, + onResumeSelected: _resumeSelectedTasks, + onDeleteSelected: _deleteSelectedTasks, + ); + }, + ), + Expanded( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(32, AppDesignTokens.space8, 32, 0), + child: Column( + key: const ValueKey('tasks-content-start'), + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: _batchMode + ? ListenableBuilder( + listenable: _batchSelection, + builder: (context, _) => _TaskBatchRow( + selectedCount: _batchSelection.count, + totalCount: filteredTasks.length, + allSelected: _batchSelection.areAllSelected(visibleTaskIds), + someSelected: + _batchSelection.isNotEmpty && + !_batchSelection.areAllSelected(visibleTaskIds), + onToggleSelectAll: _toggleSelectAll, + ), + ) + : const SizedBox.shrink(), + ), + Expanded( + child: tasksAsync.when( + loading: () => const Center(child: shad.CircularProgressIndicator()), + error: (error, _) => const TaskEmptyState(), + data: (_) => filteredTasks.isEmpty + ? TaskEmptyState(message: emptyStateMessage) + : ListView.separated( + padding: const EdgeInsets.only(bottom: 16), + itemCount: filteredTasks.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final task = filteredTasks[index]; + return TaskBatchSelectionBuilder( + controller: _batchSelection, + taskId: task.id, + visibleTaskIds: visibleTaskIds, + builder: (context, selectedInBatch, allSelected) => TaskCard( + task: task, + selected: _selectedTask?.id == task.id, + batchMode: _batchMode, + selectedInBatch: selectedInBatch, + onPressed: () => _handleTaskPressed(task), + onToggleBatch: () => _toggleBatchTask(task.id), + onPause: () => _runTaskAction( + () => ref.read(tasksControllerProvider.notifier).pause(task.id), + ), + onResume: () => _runTaskAction( + () => ref.read(tasksControllerProvider.notifier).resume(task.id), + ), + onDelete: () => unawaited(_deleteTasks([task.id])), + onOpen: () => _openTask(task), + onReveal: () => _revealTask(task), + contextActions: _taskContextActions( + task, + selected: selectedInBatch, + allSelected: allSelected, + ), + listeningForUpdate: pendingUpdateTask?.id == task.id, + ), + ); + }, + ), + ), + ), + ], + ), + ), + TaskDrawer( + task: selectedTask, + onClose: () => setState(() => _selectedTask = null), + onOpenStorage: selectedTask == null ? () {} : () => unawaited(_revealTask(selectedTask)), + onUpdateUrl: selectedTask == null + ? (_) async {} + : (url) => _updateTaskUrl(selectedTask.id, url, headers: selectedTask.requestHeaders), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + + if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { + final mobileBody = tasksAsync.when( + loading: () => _MobileTasksView( + tasks: const [], + batchMode: _batchMode, + batchSelection: _batchSelection, + loading: true, + onToggleSelectAll: _toggleSelectAll, + onPauseSelected: _pauseSelectedTasks, + onResumeSelected: _resumeSelectedTasks, + onDeleteSelected: _deleteSelectedTasks, + onTaskPressed: _handleTaskPressed, + onToggleBatch: _toggleBatchTask, + onTaskPause: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).pause(task.id)), + onTaskResume: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).resume(task.id)), + onTaskDelete: (task) => unawaited(_deleteTasks([task.id])), + onTaskOpen: _openTask, + onTaskReveal: _revealTask, + contextActionsForTask: (task, selected, allSelected) => + _taskContextActions(task, selected: selected, allSelected: allSelected), + pendingUpdateTaskId: pendingUpdateTask?.id, + ), + error: (error, _) => ColoredBox(color: palette.bg, child: const TaskEmptyState()), + data: (_) => _MobileTasksView( + tasks: filteredTasks, + emptyStateMessage: emptyStateMessage, + batchMode: _batchMode, + batchSelection: _batchSelection, + onToggleSelectAll: _toggleSelectAll, + onPauseSelected: _pauseSelectedTasks, + onResumeSelected: _resumeSelectedTasks, + onDeleteSelected: _deleteSelectedTasks, + onTaskPressed: _handleTaskPressed, + onToggleBatch: _toggleBatchTask, + onTaskPause: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).pause(task.id)), + onTaskResume: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).resume(task.id)), + onTaskDelete: (task) => unawaited(_deleteTasks([task.id])), + onTaskOpen: _openTask, + onTaskReveal: _revealTask, + contextActionsForTask: (task, selected, allSelected) => + _taskContextActions(task, selected: selected, allSelected: allSelected), + pendingUpdateTaskId: pendingUpdateTask?.id, + ), + ); + return shad.Scaffold( + backgroundColor: palette.bg, + child: Column( + children: [ + Expanded( + child: ResponsiveNavigationLayout<_TaskFilter>( + desktopTitle: context.l10n.task.toUpperCase(), + items: navigationItems, + selectedValue: _activeFilter, + onSelected: _setActiveFilter, + mobileHeader: ListenableBuilder( + listenable: _batchSelection, + builder: (context, _) => _MobileTasksHeader( + batchMode: _batchMode, + selectedCount: _batchSelection.count, + onAddTask: _handleAddTask, + onToggleBatchMode: _toggleBatchMode, + ), + ), + child: mobileBody, + ), + ), + const PrimaryBottomNavigation(), + ], + ), + ); + } + + return desktopBody; + } + + void _setActiveFilter(_TaskFilter filter) { + _batchSelection.clear(); + setState(() { + _activeFilter = filter; + _selectedTask = null; + }); + } + + List _filteredTasks(List tasks) { + final query = _searchController.text.trim().toLowerCase(); + return tasks + .where((task) { + final passesFilter = switch (_activeFilter) { + _TaskFilter.downloading => task.status == TaskStatus.downloading || task.status == TaskStatus.paused, + _TaskFilter.completed => task.status == TaskStatus.completed, + _TaskFilter.failed => task.status == TaskStatus.failed, + }; + final passesSearch = + query.isEmpty || task.name.toLowerCase().contains(query) || task.url.toLowerCase().contains(query); + return passesFilter && passesSearch; + }) + .toList(growable: false); + } + + TaskRecord? _latestSelectedTask(List tasks) { + final selectedTask = _selectedTask; + if (selectedTask == null) return null; + for (final task in tasks) { + if (task.id == selectedTask.id) return task; + } + return selectedTask; + } + + List> _taskNavigationItems(List tasks) { + int count(bool Function(TaskRecord task) test) => tasks.where(test).length; + + return [ + ResponsiveNavigationItem<_TaskFilter>( + value: _TaskFilter.downloading, + label: context.l10n.downloading, + icon: Icons.south_rounded, + count: count((task) => task.status == TaskStatus.downloading || task.status == TaskStatus.paused), + ), + ResponsiveNavigationItem<_TaskFilter>( + value: _TaskFilter.completed, + label: context.l10n.completed, + icon: Icons.check_circle_outline, + count: count((task) => task.status == TaskStatus.completed), + ), + ResponsiveNavigationItem<_TaskFilter>( + value: _TaskFilter.failed, + label: context.l10n.failed, + icon: Icons.error_outline, + count: count((task) => task.status == TaskStatus.failed), + ), + ]; + } + + void _handleTaskPressed(TaskRecord task) { + if (_batchMode) { + _toggleBatchTask(task.id); + } else { + _showTaskDetails(task); + } + } + + void _showTaskDetails(TaskRecord task) { + if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { + context.push('/tasks/${Uri.encodeComponent(task.id)}', extra: task); + } else { + setState(() { + _selectedTask = task; + }); + } + } + + void _toggleBatchMode() { + final enteringBatchMode = !_batchMode; + if (!enteringBatchMode) { + _batchSelection.clear(); + } + setState(() { + _batchMode = enteringBatchMode; + if (_batchMode) { + _selectedTask = null; + } + }); + } + + void _toggleBatchTask(String taskId) => _batchSelection.toggle(taskId); + + void _toggleSelectAll() { + final visibleIds = _filteredTasks( + ref.read(tasksControllerProvider).value?.tasks ?? const [], + ).map((task) => task.id).toList(growable: false); + if (_batchSelection.areAllSelected(visibleIds)) { + _batchSelection.clear(); + return; + } + if (!_batchMode) { + setState(() { + _batchMode = true; + _selectedTask = null; + }); + } + _batchSelection.replaceWith(visibleIds); + } + + void _deleteSelectedTasks() { + if (_batchSelection.isEmpty) return; + final ids = _batchSelection.toList(); + unawaited(_deleteTasks(ids)); + } + + void _pauseSelectedTasks() { + if (_batchMode && _batchSelection.isEmpty) return; + final ids = _batchMode ? _selectedTaskIdsFor((task) => task.status == TaskStatus.downloading) : null; + if (_batchMode && ids!.isEmpty) return; + unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).pauseAll(ids))); + } + + void _resumeSelectedTasks() { + if (_batchMode && _batchSelection.isEmpty) return; + final ids = _batchMode + ? _selectedTaskIdsFor((task) => task.status == TaskStatus.paused || task.status == TaskStatus.failed) + : null; + if (_batchMode && ids!.isEmpty) return; + unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).resumeAll(ids))); + } + + List _selectedTaskIdsFor(bool Function(TaskRecord task) test) { + final tasks = ref.read(tasksControllerProvider).value?.tasks ?? const []; + return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList(); + } + + TaskContextActions _taskContextActions(TaskRecord task, {required bool selected, required bool allSelected}) { + final listeningForUpdate = ref.read(pendingUpdateTaskProvider)?.id == task.id; + return TaskContextActions( + selected: selected, + allSelected: allSelected, + listeningForUpdate: listeningForUpdate, + onShowDetails: () => _showTaskDetails(task), + onOpenFile: () => unawaited(_openTask(task)), + onOpenDirectory: () => unawaited(_revealTask(task)), + onBrowseFiles: () => + unawaited(browseTaskFiles(context, task, mobile: MediaQuery.sizeOf(context).width < Breakpoints.mobile)), + onToggleSelectAll: _toggleSelectAll, + onToggleSelected: () { + if (!_batchMode) { + setState(() { + _batchMode = true; + _selectedTask = null; + }); + } + _toggleBatchTask(task.id); + }, + onPause: () => unawaited(_runContextTaskAction(task, pause: true)), + onResume: () => unawaited(_runContextTaskAction(task, pause: false)), + onDelete: () => unawaited(_deleteTasks(_contextTaskIds(task))), + onUpdateUrl: () => unawaited(_showUpdateUrlDialog(task)), + onToggleUpdateListener: () => _toggleUpdateListener(task), + ); + } + + List _contextTaskIds(TaskRecord task) { + final visibleIds = _filteredTasks( + ref.read(tasksControllerProvider).value?.tasks ?? const [], + ).map((item) => item.id).toSet(); + return {..._batchSelection.where(visibleIds.contains), task.id}.toList(growable: false); + } + + Future _runContextTaskAction(TaskRecord task, {required bool pause}) async { + final ids = _contextTaskIds(task); + await _runTaskAction(() { + final controller = ref.read(tasksControllerProvider.notifier); + return pause ? controller.pauseAll(ids) : controller.resumeAll(ids); + }); + if (!mounted) return; + _batchSelection.clear(); + setState(() { + _batchMode = false; + }); + } + + Future _deleteTasks(List ids) async { + if (ids.isEmpty) return; + final runtime = ref.read(appRuntimeControllerProvider).value; + final config = runtime?.downloaderConfig; + final keepFiles = await showTaskDeleteDialog( + context, + taskCount: ids.length, + keepFiles: config?.extra.lastDeleteTaskKeep ?? false, + ); + if (keepFiles == null || !mounted) return; + + await _runTaskAction(() async { + if (config != null) { + config.extra.lastDeleteTaskKeep = keepFiles; + await ref.read(gopeedServiceProvider).putConfig(config); + } + await ref.read(tasksControllerProvider.notifier).deleteSelected(ids, force: !keepFiles); + await ref.read(appRuntimeControllerProvider.notifier).reloadConfig(); + }); + if (!mounted) return; + _batchSelection.removeAll(ids); + setState(() { + _batchMode = false; + if (_selectedTask != null && ids.contains(_selectedTask!.id)) { + _selectedTask = null; + } + }); + } + + Future _showUpdateUrlDialog(TaskRecord task) async { + final update = await showTaskUpdateUrlDialog(context, task); + if (update == null || !mounted) return; + await _updateTaskUrl(task.id, update.url, headers: update.headers); + } + + void _toggleUpdateListener(TaskRecord task) { + if (!task.canUpdateUrl) return; + final notifier = ref.read(pendingUpdateTaskProvider.notifier); + if (ref.read(pendingUpdateTaskProvider)?.id == task.id) { + notifier.clear(); + } else { + notifier.set(PendingUpdateTask(id: task.id, name: task.name)); + } + } + + void _handleAddTask() { + if (!Util.isDesktop()) { + context.go('/create'); + return; + } + unawaited( + AppWindowLauncher.openCreateTaskWindow().then((opened) { + if (!opened && mounted) { + context.go('/create'); + } + }), + ); + } + + Future _runTaskAction(Future Function() action) async { + try { + await action(); + } catch (error) { + if (!mounted) return; + _showToast(error.toString()); + } + } + + Future _updateTaskUrl(String id, String url, {Map headers = const {}}) async { + try { + await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers); + final updatedTasks = ref.read(tasksControllerProvider).value?.tasks ?? const []; + for (final task in updatedTasks) { + if (task.id == id) { + if (mounted) { + setState(() => _selectedTask = task); + } + break; + } + } + } catch (error) { + if (!mounted) rethrow; + _showToast(error.toString()); + rethrow; + } + } + + Future _openTask(TaskRecord task) async { + final opened = task.isFolder + ? await FileExplorer.reveal(task.storagePath) + : await FileExplorer.open(task.storagePath); + if (!opened && mounted) { + _showToast(context.l10n.unableOpenPath(task.storagePath)); + } + } + + Future _revealTask(TaskRecord task) async { + if (!await FileExplorer.reveal(task.storagePath) && mounted) { + _showToast(context.l10n.unableLocatePath(task.storagePath)); + } + } + + void _showToast(String message) { + showAppToast(context, message, type: AppToastType.error); + } +} + +class _TaskBatchRow extends StatelessWidget { + const _TaskBatchRow({ + required this.selectedCount, + required this.totalCount, + required this.allSelected, + required this.someSelected, + required this.onToggleSelectAll, + }); + + final int selectedCount; + final int totalCount; + final bool allSelected; + final bool someSelected; + final VoidCallback onToggleSelectAll; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + + return Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + GestureDetector( + onTap: onToggleSelectAll, + child: Row( + children: [ + shad.Checkbox( + state: allSelected + ? shad.CheckboxState.checked + : someSelected + ? shad.CheckboxState.indeterminate + : shad.CheckboxState.unchecked, + onChanged: (_) => onToggleSelectAll(), + borderColor: palette.border, + backgroundColor: palette.cardBg, + size: 20, + ), + const SizedBox(width: 14), + Text( + context.l10n.selectAll.toUpperCase(), + style: TextStyle( + color: palette.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.1, + ), + ), + ], + ), + ), + const Spacer(), + Text( + context.l10n.selectedCount(selectedCount, totalCount).toUpperCase(), + style: TextStyle(color: palette.textMuted, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.1), + ), + ], + ), + ); + } +} + +class _MobileTasksView extends StatelessWidget { + const _MobileTasksView({ + required this.tasks, + required this.batchMode, + required this.batchSelection, + required this.onToggleSelectAll, + required this.onPauseSelected, + required this.onResumeSelected, + required this.onDeleteSelected, + required this.onTaskPressed, + required this.onToggleBatch, + required this.onTaskPause, + required this.onTaskResume, + required this.onTaskDelete, + required this.onTaskOpen, + required this.onTaskReveal, + required this.contextActionsForTask, + required this.pendingUpdateTaskId, + this.emptyStateMessage, + this.loading = false, + }); + + final List tasks; + final bool batchMode; + final TaskBatchSelectionController batchSelection; + final VoidCallback onToggleSelectAll; + final VoidCallback onPauseSelected; + final VoidCallback onResumeSelected; + final VoidCallback onDeleteSelected; + final ValueChanged onTaskPressed; + final ValueChanged onToggleBatch; + final ValueChanged onTaskPause; + final ValueChanged onTaskResume; + final ValueChanged onTaskDelete; + final ValueChanged onTaskOpen; + final ValueChanged onTaskReveal; + final TaskContextActions Function(TaskRecord task, bool selected, bool allSelected) contextActionsForTask; + final String? pendingUpdateTaskId; + final String? emptyStateMessage; + final bool loading; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final visibleTaskIds = tasks.map((task) => task.id).toList(growable: false); + + return ColoredBox( + color: palette.bg, + child: Column( + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: batchMode + ? ListenableBuilder( + listenable: batchSelection, + builder: (context, _) { + final allSelected = batchSelection.areAllSelected(visibleTaskIds); + final availability = _batchActionAvailability(tasks, batchSelection); + return _MobileBatchToolbar( + selectedCount: batchSelection.count, + visibleCount: tasks.length, + allSelected: allSelected, + someSelected: batchSelection.isNotEmpty && !allSelected, + canPauseSelected: availability.canPause, + canResumeSelected: availability.canResume, + onToggleSelectAll: onToggleSelectAll, + onPauseSelected: onPauseSelected, + onResumeSelected: onResumeSelected, + onDeleteSelected: onDeleteSelected, + ); + }, + ) + : const SizedBox.shrink(), + ), + Expanded( + child: loading + ? const Center(child: shad.CircularProgressIndicator()) + : tasks.isEmpty + ? TaskEmptyState(message: emptyStateMessage) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + itemCount: tasks.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final task = tasks[index]; + return TaskBatchSelectionBuilder( + controller: batchSelection, + taskId: task.id, + visibleTaskIds: visibleTaskIds, + builder: (context, selectedInBatch, allSelected) => TaskCard( + task: task, + selected: false, + batchMode: batchMode, + selectedInBatch: selectedInBatch, + onPressed: () => onTaskPressed(task), + onToggleBatch: () => onToggleBatch(task.id), + onPause: () => onTaskPause(task), + onResume: () => onTaskResume(task), + onDelete: () => onTaskDelete(task), + onOpen: () => onTaskOpen(task), + onReveal: () => onTaskReveal(task), + contextActions: contextActionsForTask(task, selectedInBatch, allSelected), + listeningForUpdate: pendingUpdateTaskId == task.id, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _MobileTasksHeader extends StatelessWidget { + const _MobileTasksHeader({ + required this.batchMode, + required this.selectedCount, + required this.onAddTask, + required this.onToggleBatchMode, + }); + + final bool batchMode; + final int selectedCount; + final VoidCallback onAddTask; + final VoidCallback onToggleBatchMode; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + decoration: BoxDecoration( + color: palette.bg, + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.taskList, + style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800), + ), + ), + _MobileHeaderIconButton(icon: Icons.add_rounded, onPressed: onAddTask), + const SizedBox(width: 8), + _MobileHeaderIconButton( + icon: Icons.checklist_rtl_outlined, + active: batchMode, + badge: batchMode && selectedCount > 0 ? selectedCount.toString() : null, + onPressed: onToggleBatchMode, + ), + ], + ), + ), + ), + ); + } +} + +class _MobileBatchToolbar extends StatelessWidget { + const _MobileBatchToolbar({ + required this.selectedCount, + required this.visibleCount, + required this.allSelected, + required this.someSelected, + required this.canPauseSelected, + required this.canResumeSelected, + required this.onToggleSelectAll, + required this.onPauseSelected, + required this.onResumeSelected, + required this.onDeleteSelected, + }); + + final int selectedCount; + final int visibleCount; + final bool allSelected; + final bool someSelected; + final bool canPauseSelected; + final bool canResumeSelected; + final VoidCallback onToggleSelectAll; + final VoidCallback onPauseSelected; + final VoidCallback onResumeSelected; + final VoidCallback onDeleteSelected; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: palette.sideBg, + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleSelectAll, + child: Row( + children: [ + shad.Checkbox( + state: allSelected + ? shad.CheckboxState.checked + : someSelected + ? shad.CheckboxState.indeterminate + : shad.CheckboxState.unchecked, + onChanged: (_) => onToggleSelectAll(), + borderColor: palette.border, + backgroundColor: palette.cardBg, + ), + const SizedBox(width: AppDesignTokens.checkboxLabelGap), + Text( + '$selectedCount / $visibleCount', + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w700), + ), + ], + ), + ), + const Spacer(), + _MobileIconAction(icon: Icons.pause_outlined, onPressed: onPauseSelected, enabled: canPauseSelected), + const SizedBox(width: 6), + _MobileIconAction(icon: Icons.play_arrow_outlined, onPressed: onResumeSelected, enabled: canResumeSelected), + const SizedBox(width: 6), + _MobileIconAction(icon: Icons.delete_outline, onPressed: onDeleteSelected, enabled: selectedCount > 0), + ], + ), + ); + } +} + +class _MobileHeaderIconButton extends StatelessWidget { + const _MobileHeaderIconButton({required this.icon, required this.onPressed, this.active = false, this.badge}); + + final IconData icon; + final VoidCallback onPressed; + final bool active; + final String? badge; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: active ? palette.itemActiveBg : palette.cardBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: Border.all(color: active ? palette.textPrimary : palette.border), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Center(child: Icon(icon, size: 18, color: active ? palette.textPrimary : palette.textSecondary)), + if (badge != null) + Positioned( + right: -5, + top: -5, + child: Container( + constraints: const BoxConstraints(minWidth: 16), + height: 16, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration(color: palette.brand, borderRadius: BorderRadius.circular(999)), + child: Center( + child: Text( + badge!, + style: TextStyle(color: palette.brandForeground, fontSize: 9, fontWeight: FontWeight.w800), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _MobileIconAction extends StatelessWidget { + const _MobileIconAction({required this.icon, required this.onPressed, required this.enabled}); + + final IconData icon; + final VoidCallback onPressed; + final bool enabled; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? onPressed : null, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: enabled ? palette.cardBg : Colors.transparent, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: Border.all(color: enabled ? palette.border : Colors.transparent), + ), + child: Icon(icon, size: 18, color: enabled ? palette.textSecondary : palette.textMuted), + ), + ); + } +} diff --git a/ui/flutter/lib/features/home/presentation/widgets/filter_sidebar.dart b/ui/flutter/lib/features/home/presentation/widgets/filter_sidebar.dart new file mode 100644 index 00000000..d0478212 --- /dev/null +++ b/ui/flutter/lib/features/home/presentation/widgets/filter_sidebar.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; + +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../tasks/domain/task_record.dart'; +import '../../../tasks/presentation/widgets/speed_monitor_card.dart'; + +enum TaskFilter { all, downloading, completed, paused, failed } + +class FilterSidebar extends StatelessWidget { + const FilterSidebar({super.key, required this.activeFilter, required this.onFilterSelected, required this.tasks}); + + final TaskFilter activeFilter; + final ValueChanged onFilterSelected; + final List tasks; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final titleStyle = TextStyle( + color: palette.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 2.4, + ); + + return Container( + width: AppDesignTokens.filterSidebarWidth, + color: palette.sideBg, + padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: AppDesignTokens.contentHeaderHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text(context.l10n.filters.toUpperCase(), style: titleStyle), + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _FilterItem( + label: context.l10n.allTasks, + icon: Icons.grid_view_rounded, + active: activeFilter == TaskFilter.all, + count: tasks.length.toString(), + onTap: () => onFilterSelected(TaskFilter.all), + ), + _FilterItem( + label: context.l10n.downloading, + icon: Icons.south_rounded, + active: activeFilter == TaskFilter.downloading, + count: _count(TaskStatus.downloading).toString(), + onTap: () => onFilterSelected(TaskFilter.downloading), + ), + _FilterItem( + label: context.l10n.completed, + icon: Icons.check_circle_outline, + active: activeFilter == TaskFilter.completed, + onTap: () => onFilterSelected(TaskFilter.completed), + ), + _FilterItem( + label: context.l10n.pause, + icon: Icons.pause_circle_outline, + active: activeFilter == TaskFilter.paused, + onTap: () => onFilterSelected(TaskFilter.paused), + ), + _FilterItem( + label: context.l10n.failed, + icon: Icons.error_outline, + active: activeFilter == TaskFilter.failed, + onTap: () => onFilterSelected(TaskFilter.failed), + ), + ], + ), + ), + const Spacer(), + const SpeedMonitorCard(), + ], + ), + ); + } + + int _count(TaskStatus status) { + return tasks.where((task) => task.status == status).length; + } +} + +class _FilterItem extends StatelessWidget { + const _FilterItem({required this.label, required this.icon, required this.active, required this.onTap, this.count}); + + final String label; + final IconData icon; + final bool active; + final VoidCallback onTap; + final String? count; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: active ? palette.itemActiveBg : Colors.transparent, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: active ? palette.textPrimary : palette.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (count != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: palette.cardHoverBg, borderRadius: BorderRadius.circular(4)), + child: Text( + count!, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/home/presentation/widgets/layout_constants.dart b/ui/flutter/lib/features/home/presentation/widgets/layout_constants.dart new file mode 100644 index 00000000..463c2688 --- /dev/null +++ b/ui/flutter/lib/features/home/presentation/widgets/layout_constants.dart @@ -0,0 +1,6 @@ +const double kWindowTaskbarHeight = 30; +const double kColumnHeaderContentHeight = 30; +const double kColumnHeaderTotalHeight = kWindowTaskbarHeight + kColumnHeaderContentHeight; +const double kContentTopBarHeight = 40; +const double kSidebarItemHeight = 40; +const double kSidebarItemGap = 3; diff --git a/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart b/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart new file mode 100644 index 00000000..6401e1de --- /dev/null +++ b/ui/flutter/lib/features/home/presentation/widgets/primary_rail.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../../../shared/widgets/gopeed_app_mark.dart'; +import '../../../../l10n/l10n.dart'; + +enum RailSection { tasks, extensions, settings } + +class PrimaryRail extends StatelessWidget { + const PrimaryRail({super.key, this.activeSection = RailSection.tasks}); + + final RailSection activeSection; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + + return Container( + width: AppDesignTokens.railWidth, + color: palette.railBg, + padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0), + child: Column( + children: [ + SizedBox( + height: AppDesignTokens.contentHeaderHeight, + child: const Center(child: GopeedAppMark(key: ValueKey('primary-rail-app-mark'))), + ), + const SizedBox(height: AppDesignTokens.space8), + _RailItem( + key: const ValueKey('primary-rail-tasks-item'), + icon: Icons.download_rounded, + label: context.l10n.task, + active: activeSection == RailSection.tasks, + onTap: () => context.go('/'), + ), + const SizedBox(height: 28), + _RailItem( + icon: Icons.extension_outlined, + label: context.l10n.extensions, + active: activeSection == RailSection.extensions, + onTap: () => context.go('/extensions'), + ), + const SizedBox(height: 28), + _RailItem( + icon: Icons.settings_outlined, + label: context.l10n.setting, + active: activeSection == RailSection.settings, + onTap: () => context.go('/settings'), + ), + const Spacer(), + ], + ), + ); + } +} + +class PrimaryBottomNavigation extends StatelessWidget { + const PrimaryBottomNavigation({super.key, this.activeSection = RailSection.tasks}); + + final RailSection activeSection; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + decoration: BoxDecoration( + color: palette.railBg, + border: Border(top: BorderSide(color: palette.border)), + ), + child: SafeArea( + top: false, + child: SizedBox( + height: 64, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Expanded( + child: _BottomNavItem( + icon: Icons.download_rounded, + label: context.l10n.task, + active: activeSection == RailSection.tasks, + onTap: () => context.go('/'), + ), + ), + Expanded( + child: _BottomNavItem( + icon: Icons.extension_outlined, + label: context.l10n.extensions, + active: activeSection == RailSection.extensions, + onTap: () => context.go('/extensions'), + ), + ), + Expanded( + child: _BottomNavItem( + icon: Icons.settings_outlined, + label: context.l10n.setting, + active: activeSection == RailSection.settings, + onTap: () => context.go('/settings'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _RailItem extends StatelessWidget { + const _RailItem({super.key, required this.icon, required this.label, required this.onTap, this.active = false}); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool active; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return AppTooltip( + message: label, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: AppDesignTokens.railWidth, + height: 24, + child: Stack( + children: [ + if (active) + Align( + alignment: Alignment.centerLeft, + child: Container( + key: const ValueKey('primary-rail-active-indicator'), + width: 2, + height: 24, + color: palette.textPrimary, + ), + ), + Center(child: Icon(icon, size: 18, color: active ? palette.textPrimary : palette.textMuted)), + ], + ), + ), + ), + ), + ); + } +} + +class _BottomNavItem extends StatelessWidget { + const _BottomNavItem({required this.icon, required this.label, required this.onTap, this.active = false}); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool active; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: 54, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 19, color: active ? palette.textPrimary : palette.textMuted), + const SizedBox(height: 4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: active ? palette.textPrimary : palette.textMuted, + fontSize: 10, + fontWeight: active ? FontWeight.w700 : FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart new file mode 100644 index 00000000..569588c6 --- /dev/null +++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../l10n/l10n.dart'; + +class TasksTopBar extends StatelessWidget { + const TasksTopBar({ + super.key, + required this.searchController, + required this.onAddTask, + required this.batchMode, + required this.selectedBatchCount, + required this.canPauseSelected, + required this.canResumeSelected, + required this.onToggleBatchMode, + required this.onPauseSelected, + required this.onResumeSelected, + required this.onDeleteSelected, + }); + + final TextEditingController searchController; + final VoidCallback onAddTask; + final bool batchMode; + final int selectedBatchCount; + final bool canPauseSelected; + final bool canResumeSelected; + final VoidCallback onToggleBatchMode; + final VoidCallback onPauseSelected; + final VoidCallback onResumeSelected; + final VoidCallback onDeleteSelected; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final headerBorder = palette.border; + + return SizedBox( + height: AppDesignTokens.contentHeaderHeight, + child: Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(left: 32, right: 32), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + key: const ValueKey('tasks-search-field-container'), + constraints: const BoxConstraints(maxWidth: 240), + child: _SearchField(controller: searchController, palette: palette, borderColor: headerBorder), + ), + ), + ), + const SizedBox(width: 16), + ConstrainedBox( + key: const ValueKey('tasks-create-button-container'), + constraints: const BoxConstraints(maxWidth: 190), + child: _AddTaskButton(onPressed: onAddTask), + ), + const SizedBox(width: 16), + Row( + key: const ValueKey('tasks-top-action-buttons'), + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _ActionIconButton( + icon: Icons.pause_outlined, + onPressed: !batchMode || canPauseSelected ? onPauseSelected : null, + ), + const SizedBox(width: 4), + _ActionIconButton( + icon: Icons.play_arrow_outlined, + onPressed: !batchMode || canResumeSelected ? onResumeSelected : null, + ), + const SizedBox(width: 4), + _ActionIconButton( + icon: Icons.delete_outline, + onPressed: selectedBatchCount > 0 ? onDeleteSelected : null, + ), + const SizedBox(width: 4), + _ActionIconButton(icon: Icons.checklist_rtl_outlined, onPressed: onToggleBatchMode), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _SearchField extends StatelessWidget { + const _SearchField({required this.controller, required this.palette, required this.borderColor}); + + final TextEditingController controller; + final AppPalette palette; + final Color borderColor; + + @override + Widget build(BuildContext context) { + return shad.ComponentTheme( + data: const shad.FocusOutlineTheme( + align: 0, + border: Border.fromBorderSide(BorderSide(color: Colors.transparent, width: 0)), + ), + child: SizedBox( + height: 40, + child: shad.TextField( + controller: controller, + style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1), + placeholder: Text( + context.l10n.searchDownloads, + style: TextStyle(color: palette.searchHint, fontSize: 14, height: 1), + ), + features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 14, color: palette.textMuted))], + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + decoration: BoxDecoration( + color: palette.inputBg, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: borderColor), + ), + ), + ), + ); + } +} + +class _AddTaskButton extends StatelessWidget { + const _AddTaskButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return AppPrimaryButton( + onPressed: onPressed, + leading: const Icon(Icons.add), + child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis), + ); + } +} + +class _ActionIconButton extends StatelessWidget { + const _ActionIconButton({required this.icon, required this.onPressed}); + + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 30, + child: shad.IconButton.outline(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: Icon(icon, size: 18)), + ); + } +} diff --git a/ui/flutter/lib/features/home/presentation/widgets/window_chrome_overlay.dart b/ui/flutter/lib/features/home/presentation/widgets/window_chrome_overlay.dart new file mode 100644 index 00000000..a6037792 --- /dev/null +++ b/ui/flutter/lib/features/home/presentation/widgets/window_chrome_overlay.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/window/desktop_window_header.dart'; +import 'layout_constants.dart'; + +class WindowChromeOverlay extends StatelessWidget { + const WindowChromeOverlay({super.key}); + + @override + Widget build(BuildContext context) { + final isWindowsDesktop = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; + + return SizedBox( + height: kWindowTaskbarHeight, + child: Row( + children: [ + SizedBox( + width: 64 + 264, + child: isWindowsDesktop ? const DragToMoveArea(child: SizedBox.expand()) : const SizedBox.shrink(), + ), + const Expanded(child: _WindowTaskbar()), + ], + ), + ); + } +} + +class _WindowTaskbar extends StatelessWidget { + const _WindowTaskbar(); + + @override + Widget build(BuildContext context) { + final isWindowsDesktop = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; + final palette = AppPalette.of(context); + + return Container( + color: palette.bg, + child: Row( + children: [ + Expanded(child: isWindowsDesktop ? const DragToMoveArea(child: SizedBox.expand()) : const SizedBox.shrink()), + if (isWindowsDesktop) const WindowCaptionControls(), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/features/search/README.md b/ui/flutter/lib/features/search/README.md new file mode 100644 index 00000000..150831df --- /dev/null +++ b/ui/flutter/lib/features/search/README.md @@ -0,0 +1 @@ +# Search feature diff --git a/ui/flutter/lib/features/settings/README.md b/ui/flutter/lib/features/settings/README.md new file mode 100644 index 00000000..4472f45f --- /dev/null +++ b/ui/flutter/lib/features/settings/README.md @@ -0,0 +1 @@ +# Settings feature diff --git a/ui/flutter/lib/features/settings/application/settings_controller.dart b/ui/flutter/lib/features/settings/application/settings_controller.dart new file mode 100644 index 00000000..d6fe41ae --- /dev/null +++ b/ui/flutter/lib/features/settings/application/settings_controller.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/downloader_config.dart'; +import '../../../app/application/app_runtime_controller.dart'; +import '../../../core/capabilities/app_capabilities.dart'; + +final settingsControllerProvider = AsyncNotifierProvider.autoDispose( + SettingsController.new, +); + +class SettingsState { + const SettingsState({required this.config, this.saving = false}); + + final DownloaderConfig config; + final bool saving; + + SettingsState copyWith({DownloaderConfig? config, bool? saving}) { + return SettingsState(config: config ?? this.config, saving: saving ?? this.saving); + } +} + +class SettingsController extends AsyncNotifier { + @override + Future build() async { + await ref.read(appRuntimeControllerProvider.future); + return SettingsState(config: await ref.read(gopeedServiceProvider).getConfig()); + } + + Future reload({bool showLoading = true}) async { + if (showLoading) state = const AsyncValue.loading(); + state = AsyncValue.data(SettingsState(config: await ref.read(gopeedServiceProvider).getConfig())); + } + + Future save(DownloaderConfig config) async { + state = AsyncValue.data(SettingsState(config: config, saving: true)); + await ref.read(gopeedServiceProvider).putConfig(config); + ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(config); + state = AsyncValue.data(SettingsState(config: config)); + } + + Future testWebhook(String url) async { + await ref.read(gopeedServiceProvider).testWebhook(url); + } +} diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart new file mode 100644 index 00000000..de07a5b3 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -0,0 +1,2176 @@ +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../api/model/downloader_config.dart'; +import '../../../../app/application/app_appearance_controller.dart'; +import '../../../../app/application/app_platform_controller.dart'; +import '../../../../app/application/app_runtime_controller.dart'; +import '../../../../app/application/location_keep_alive.dart'; +import '../../../../core/common/start_config.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/utils/tcp_port_checker.dart'; +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../database/database.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_choice_segmented_control.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_path_picker_field.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/responsive_menu_layout.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../util/log_util.dart'; +import '../../../../util/package_info.dart'; +import '../../../../util/scheme_register/scheme_register.dart'; +import '../../../../util/util.dart'; +import '../../../home/presentation/widgets/primary_rail.dart'; +import '../../application/settings_controller.dart'; +import '../widgets/app_update_dialog.dart'; +import '../widgets/download_categories_setting.dart'; +import '../widgets/settings_language_select.dart'; +import '../widgets/settings_content_frame.dart'; +import '../widgets/settings_item.dart'; +import '../widgets/settings_list_editor.dart'; +import '../widgets/theme_settings_control.dart'; + +enum _SettingsSection { basic, downloads, advanced } + +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key, this.sectionKey}); + + final String? sectionKey; + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + final _downloadDirController = TextEditingController(); + final _maxRunningController = TextEditingController(); + final _httpUserAgentController = TextEditingController(); + final _httpConnectionsController = TextEditingController(); + final _btListenPortController = TextEditingController(); + final _btSeedRatioController = TextEditingController(); + final _btSeedTimeController = TextEditingController(); + final _ed2kListenPortController = TextEditingController(); + final _ed2kUdpPortController = TextEditingController(); + final _ed2kServerAddrController = TextEditingController(); + final _ed2kServerMetController = TextEditingController(); + final _ed2kNodesDatController = TextEditingController(); + final _proxyHostController = TextEditingController(); + final _proxyPortController = TextEditingController(); + final _proxyUserController = TextEditingController(); + final _proxyPasswordController = TextEditingController(); + final _customTrackersController = TextEditingController(); + final _apiAddressController = TextEditingController(); + final _apiHostController = TextEditingController(); + final _apiPortController = TextEditingController(); + final _apiTokenController = TextEditingController(); + + DownloaderConfig? _config; + StartConfig? _startConfig; + String? _loadedSignature; + String? _loadedStartSignature; + Timer? _textSaveTimer; + bool _syncingControllers = false; + bool _syncingStartControllers = false; + bool _savingStartConfig = false; + String? _apiNetworkDraft; + late bool _analyticsEnabled; + + List get _textControllers => [ + _downloadDirController, + _maxRunningController, + _httpUserAgentController, + _httpConnectionsController, + _btListenPortController, + _btSeedRatioController, + _btSeedTimeController, + _ed2kListenPortController, + _ed2kUdpPortController, + _ed2kServerAddrController, + _ed2kServerMetController, + _ed2kNodesDatController, + _proxyHostController, + _proxyPortController, + _proxyUserController, + _proxyPasswordController, + _customTrackersController, + ]; + + List get _startTextControllers => [ + _apiAddressController, + _apiHostController, + _apiPortController, + _apiTokenController, + ]; + + @override + void initState() { + super.initState(); + _analyticsEnabled = Database.instance.initialized ? Database.instance.getAnalyticsEnabled() : true; + for (final controller in _textControllers) { + controller.addListener(_scheduleTextSave); + } + for (final controller in _startTextControllers) { + controller.addListener(_handleStartDraftChanged); + } + } + + @override + void dispose() { + _textSaveTimer?.cancel(); + for (final controller in _textControllers) { + controller.removeListener(_scheduleTextSave); + controller.dispose(); + } + for (final controller in _startTextControllers) { + controller.removeListener(_handleStartDraftChanged); + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final stateAsync = ref.watch(settingsControllerProvider); + final runtimeState = ref.watch(appRuntimeControllerProvider).value; + final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + final selectedSection = _sectionFromKey(widget.sectionKey) ?? _SettingsSection.basic; + final content = stateAsync.when( + loading: () => const Center(child: shad.CircularProgressIndicator()), + error: (error, _) => + _ErrorState(error: error, onRetry: () => ref.read(settingsControllerProvider.notifier).reload()), + data: (state) { + _syncConfig(state.config); + if (runtimeState != null) { + _syncStartConfig(runtimeState.startConfig); + } + return ResponsiveMenuLayout<_SettingsSection>( + title: context.l10n.setting, + items: _menuItems, + selectedValue: selectedSection, + onSelected: (section) => _selectSection(section, isDesktop: isDesktop), + mobileContentTitleBuilder: _sectionTitle, + mobileContentVisible: widget.sectionKey != null, + onMobileBack: _leaveMobileSection, + contentBuilder: (context, section) => _buildSectionContent(section, runtimeState), + ); + }, + ); + + return shad.Scaffold( + backgroundColor: palette.bg, + child: isDesktop + ? Row( + children: [ + const PrimaryRail(activeSection: RailSection.settings), + Expanded(child: content), + ], + ) + : Column( + children: [ + Expanded(child: content), + const PrimaryBottomNavigation(activeSection: RailSection.settings), + ], + ), + ); + } + + void _selectSection(_SettingsSection section, {required bool isDesktop}) { + final location = '/settings/${section.name}'; + if (isDesktop) { + context.go(location); + } else { + context.push(location); + } + } + + void _leaveMobileSection() { + if (context.canPop()) { + context.pop(); + } else { + context.go('/settings'); + } + } + + _SettingsSection? _sectionFromKey(String? key) { + for (final section in _SettingsSection.values) { + if (section.name == key) return section; + } + return null; + } + + List> get _menuItems => [ + ResponsiveMenuItem(value: _SettingsSection.basic, label: context.l10n.basicSettings, icon: Icons.tune_outlined), + ResponsiveMenuItem( + value: _SettingsSection.downloads, + label: context.l10n.downloadSettings, + icon: Icons.download_outlined, + ), + ResponsiveMenuItem( + value: _SettingsSection.advanced, + label: context.l10n.advancedSettings, + icon: Icons.code_outlined, + ), + ]; + + Widget _buildSectionContent(_SettingsSection section, AppRuntimeState? runtimeState) { + final config = _config!; + final palette = AppPalette.of(context); + final platform = ref.watch(appPlatformControllerProvider).value; + final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + + return Padding( + padding: EdgeInsets.only( + top: isDesktop && AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0, + ), + child: Column( + children: [ + if (isDesktop) + SizedBox( + height: AppDesignTokens.contentHeaderHeight, + child: SettingsContentFrame( + child: SizedBox( + key: const ValueKey('settings-header-content'), + width: double.infinity, + child: Row( + children: [ + Text( + _sectionTitle(section), + style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800), + ), + ], + ), + ), + ), + ), + Expanded( + child: ListView( + key: const ValueKey('settings-section-scroll-view'), + padding: const EdgeInsets.only(top: AppDesignTokens.space8, bottom: 28), + children: [ + SettingsContentFrame( + child: SizedBox( + key: const ValueKey('settings-body-content'), + width: double.infinity, + child: _sectionBody(section, config, platform, runtimeState), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _sectionBody( + _SettingsSection section, + DownloaderConfig config, + AppPlatformState? platform, + AppRuntimeState? runtimeState, + ) { + final apiConfigDirty = _startConfig != null && _startDraftSignature() != _startSignature(_startConfig!); + switch (section) { + case _SettingsSection.basic: + return _SettingsSectionStack( + children: [ + _SettingsBlock( + title: context.l10n.ui, + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.theme, + child: ThemeModeSelector( + value: ref.watch(appAppearanceControllerProvider).themeMode, + accent: ref.watch(appAppearanceControllerProvider).themeColor, + onChanged: (mode) { + ref.read(appAppearanceControllerProvider.notifier).setThemeMode(mode); + _mutateConfig((next) => next.extra.themeMode = mode.key); + }, + ), + ), + SettingsItem( + title: context.l10n.themeColor, + subtitle: context.l10n.themeColorDescription, + child: ThemeColorSelector( + value: ref.watch(appAppearanceControllerProvider).themeColor, + onChanged: (color) { + ref.read(appAppearanceControllerProvider.notifier).setThemeColor(color); + _mutateConfig((next) => next.extra.themeColor = color.key); + }, + ), + ), + SettingsItem( + title: context.l10n.locale, + child: SettingsLanguageSelect( + value: _languageValue(config), + onChanged: (value) { + _mutateConfig((next) => next.extra.locale = value == 'system' ? '' : value); + }, + ), + ), + ], + ), + ), + _SettingsBlock( + title: context.l10n.general, + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.browserExtension, + subtitle: context.l10n.browserExtensionDescription, + child: _BrowserExtensionLinks(onOpen: _openBrowserExtension), + ), + if (Util.isWindows() || Util.isLinux()) + SettingsItem( + title: context.l10n.launchAtStartup, + child: shad.Switch( + value: platform?.launchAtStartup ?? false, + onChanged: (value) => _runAction( + () => ref.read(appPlatformControllerProvider.notifier).setLaunchAtStartup(value), + ), + ), + ), + if (Util.isMacos()) + SettingsItem( + title: context.l10n.runAsMenubarApp, + subtitle: context.l10n.runAsMenubarAppDesc, + child: shad.Switch( + value: platform?.runAsMenubarApp ?? false, + onChanged: (value) => _runAction( + () => ref.read(appPlatformControllerProvider.notifier).setRunAsMenubarApp(value), + ), + ), + ), + if (Util.isDesktop()) + SettingsItem( + title: context.l10n.desktopNotification, + subtitle: context.l10n.desktopNotificationDescription, + child: shad.Switch( + value: config.extra.desktopNotification, + onChanged: (value) => _mutateConfig((next) => next.extra.desktopNotification = value), + ), + ), + if (Util.isIOS()) + SettingsItem( + title: context.l10n.backgroundLocationKeepAlive, + subtitle: context.l10n.backgroundLocationKeepAliveDescription, + child: shad.Switch( + value: config.extra.backgroundLocationKeepAlive, + onChanged: (value) => unawaited(_setBackgroundLocationKeepAlive(value)), + ), + ), + ], + ), + ), + _SettingsBlock( + title: context.l10n.about, + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.homepage, + child: _ExternalTextLink( + key: const ValueKey('gopeed-homepage'), + label: 'gopeed.com', + onPressed: () => unawaited(_openExternalUri(Uri.parse('https://gopeed.com'))), + ), + ), + SettingsItem( + title: 'GitHub', + child: _ExternalTextLink( + key: const ValueKey('gopeed-github'), + label: 'github.com/GopeedLab/gopeed', + onPressed: () => unawaited(_openExternalUri(Uri.parse('https://github.com/GopeedLab/gopeed'))), + ), + ), + SettingsItem( + title: context.l10n.contributors, + subtitle: context.l10n.thanksDesc, + child: _ExternalTextLink( + key: const ValueKey('gopeed-contributors'), + label: context.l10n.viewContributors, + onPressed: () => unawaited( + _openExternalUri(Uri.parse('https://github.com/GopeedLab/gopeed/graphs/contributors')), + ), + ), + ), + SettingsItem( + title: context.l10n.analyticsEnabled, + subtitle: context.l10n.analyticsEnabledDesc, + child: shad.Switch( + value: _analyticsEnabled, + onChanged: (value) { + setState(() => _analyticsEnabled = value); + if (Database.instance.initialized) Database.instance.saveAnalyticsEnabled(value); + }, + ), + ), + SettingsItem( + title: context.l10n.notifyWhenNewVersion, + child: shad.Switch( + value: config.extra.notifyWhenNewVersion, + onChanged: (value) => _mutateConfig((next) => next.extra.notifyWhenNewVersion = value), + ), + ), + SettingsItem( + title: context.l10n.version, + subtitle: context.l10n.currentVersion(_versionLabel), + child: _UpdateActionControl(platform: platform), + ), + ], + ), + ), + ], + ); + case _SettingsSection.downloads: + return _SettingsSectionStack( + children: [ + _SettingsBlock( + title: context.l10n.general, + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.downloadDir, + child: AppPathPickerField.downloadDirectory( + fieldKey: const ValueKey('download-directory-input'), + controller: _downloadDirController, + pickerKey: const ValueKey('download-directory-picker'), + desktopWidth: AppDesignTokens.settingsFormControlWidth, + ), + ), + SettingsItem( + title: context.l10n.downloadCategories, + child: DownloadCategoriesControl( + categories: config.extra.downloadCategories, + displayName: _categoryName, + onAdd: () => unawaited(_editDownloadCategory()), + onEdit: (category) => unawaited(_editDownloadCategory(category)), + onDelete: (category) => unawaited(_deleteDownloadCategory(category)), + ), + ), + SettingsItem( + title: context.l10n.maxRunning, + subtitle: context.l10n.maxRunningDescription, + child: _NumberSettingControl( + fieldKey: const ValueKey('max-running-input'), + controller: _maxRunningController, + min: 1, + max: 256, + ), + ), + SettingsItem( + title: context.l10n.defaultDirectDownload, + child: shad.Switch( + value: config.extra.defaultDirectDownload, + onChanged: (value) => _mutateConfig((next) => next.extra.defaultDirectDownload = value), + ), + ), + SettingsItem( + title: context.l10n.autoStartTasks, + child: shad.Switch( + value: config.autoStartTasks, + onChanged: (value) => _mutateConfig((next) => next.autoStartTasks = value), + ), + ), + SettingsItem( + title: context.l10n.autoTorrentEnable, + child: shad.Switch( + value: config.autoTorrent.enable, + onChanged: (value) => _mutateConfig((next) => next.autoTorrent.enable = value), + ), + ), + if (config.autoTorrent.enable) + SettingsItem( + title: context.l10n.autoTorrentDeleteAfterDownload, + child: shad.Switch( + value: config.autoTorrent.deleteAfterDownload, + onChanged: (value) => _mutateConfig((next) => next.autoTorrent.deleteAfterDownload = value), + ), + ), + SettingsItem( + title: context.l10n.autoDeleteMissingFileTasks, + child: shad.Switch( + value: config.autoDeleteMissingFileTasks, + onChanged: (value) => _mutateConfig((next) => next.autoDeleteMissingFileTasks = value), + ), + ), + ], + ), + ), + _SettingsBlock( + key: const ValueKey('settings-http-block'), + title: 'HTTP', + child: _SettingsGroup( + children: [ + SettingsItem( + title: 'User-Agent', + subtitle: context.l10n.defaultUserAgentDescription, + child: _TextSettingControl( + fieldKey: const ValueKey('http-user-agent-input'), + controller: _httpUserAgentController, + ), + ), + SettingsItem( + title: context.l10n.connections, + child: _NumberSettingControl( + fieldKey: const ValueKey('http-connections-input'), + controller: _httpConnectionsController, + min: 1, + max: 256, + ), + ), + SettingsItem( + title: context.l10n.useServerCtime, + child: shad.Switch( + value: config.protocolConfig.http.useServerCtime, + onChanged: (value) => _mutateConfig((next) => next.protocolConfig.http.useServerCtime = value), + ), + ), + SettingsItem( + title: context.l10n.autoExtract, + child: shad.Switch( + value: config.archive.autoExtract, + onChanged: (value) => _mutateConfig((next) => next.archive.autoExtract = value), + ), + ), + if (config.archive.autoExtract) + SettingsItem( + title: context.l10n.deleteAfterExtract, + child: shad.Switch( + value: config.archive.deleteAfterExtract, + onChanged: (value) => _mutateConfig((next) => next.archive.deleteAfterExtract = value), + ), + ), + ], + ), + ), + _SettingsBlock( + title: 'BitTorrent', + child: _SettingsGroup( + children: [ + if (Util.isWindows()) + SettingsItem( + title: context.l10n.setAsDefaultBtClient, + child: shad.Switch( + value: config.extra.defaultBtClient, + onChanged: (value) => unawaited(_setDefaultBtClient(value)), + ), + ), + SettingsItem( + title: context.l10n.listenPort, + child: _NumberSettingControl( + fieldKey: const ValueKey('bt-listen-port-input'), + controller: _btListenPortController, + min: 0, + max: 65535, + ), + ), + SettingsItem( + title: context.l10n.seedKeep, + child: shad.Switch( + value: config.protocolConfig.bt.seedKeep, + onChanged: (value) => _mutateConfig((next) => next.protocolConfig.bt.seedKeep = value), + ), + ), + if (!config.protocolConfig.bt.seedKeep) ...[ + SettingsItem( + title: context.l10n.seedRatio, + child: _NumberSettingControl( + fieldKey: const ValueKey('bt-seed-ratio-input'), + controller: _btSeedRatioController, + min: 0, + step: 0.1, + decimalPlaces: 2, + ), + ), + SettingsItem( + title: context.l10n.seedTime, + child: _NumberSettingControl( + fieldKey: const ValueKey('bt-seed-time-input'), + controller: _btSeedTimeController, + min: 0, + max: 100000000, + ), + ), + ], + SettingsItem( + title: context.l10n.subscribeTracker, + child: _TrackerSubscriptionsControl( + selected: config.extra.bt.trackerSubscribeUrls, + autoUpdate: config.extra.bt.autoUpdateTrackers, + lastUpdated: config.extra.bt.lastTrackerUpdateTime, + onChanged: (urls) => _mutateConfig((next) => next.extra.bt.trackerSubscribeUrls = urls), + onAutoUpdateChanged: (value) => _mutateConfig((next) => next.extra.bt.autoUpdateTrackers = value), + onUpdate: _updateTrackers, + ), + ), + SettingsItem( + title: context.l10n.addTracker, + subtitle: context.l10n.onePerLine, + child: _TextSettingControl(controller: _customTrackersController, minLines: 7), + ), + ], + ), + ), + _SettingsBlock( + title: 'ED2K', + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.ed2kTcpPort, + child: _NumberSettingControl( + fieldKey: const ValueKey('ed2k-tcp-port-input'), + controller: _ed2kListenPortController, + min: 0, + max: 65535, + ), + ), + SettingsItem( + title: context.l10n.ed2kUdpPort, + child: _NumberSettingControl( + fieldKey: const ValueKey('ed2k-udp-port-input'), + controller: _ed2kUdpPortController, + min: 0, + max: 65535, + ), + ), + SettingsItem( + title: context.l10n.ed2kServerList, + subtitle: context.l10n.onePerLine, + child: _TextSettingControl( + fieldKey: const ValueKey('ed2k-server-address-input'), + hintText: context.l10n.ed2kServersHint, + controller: _ed2kServerAddrController, + minLines: 5, + ), + ), + SettingsItem( + title: context.l10n.ed2kServerMet, + subtitle: context.l10n.onePerLine, + child: _TextSettingControl( + fieldKey: const ValueKey('ed2k-server-met-input'), + hintText: context.l10n.ed2kServerMetHint, + controller: _ed2kServerMetController, + minLines: 4, + ), + ), + SettingsItem( + title: context.l10n.ed2kNodesDat, + subtitle: context.l10n.onePerLine, + child: _TextSettingControl( + fieldKey: const ValueKey('ed2k-nodes-dat-input'), + hintText: context.l10n.ed2kNodesDatHint, + controller: _ed2kNodesDatController, + minLines: 4, + ), + ), + ], + ), + ), + ], + ); + case _SettingsSection.advanced: + return _SettingsSectionStack( + children: [ + _SettingsBlock( + title: context.l10n.network, + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.proxy, + child: AppChoiceSegmentedControl( + value: _proxyMode, + buttonKeyPrefix: 'settings-choice', + options: [ + AppChoiceOption(value: 'none', label: context.l10n.noProxy, icon: Icons.block_outlined), + AppChoiceOption( + value: 'system', + label: context.l10n.systemProxy, + icon: Icons.computer_outlined, + ), + AppChoiceOption(value: 'custom', label: context.l10n.customProxy, icon: Icons.tune_outlined), + ], + onChanged: (value) => _mutateConfig((next) { + next.proxy.enable = value != 'none'; + next.proxy.system = value == 'system'; + }), + ), + ), + if (_proxyMode == 'custom') ...[ + SettingsItem( + title: context.l10n.proxyProtocol, + child: AppChoiceSegmentedControl( + value: _proxyScheme, + buttonKeyPrefix: 'settings-choice', + options: const [ + AppChoiceOption(value: 'http', label: 'HTTP', icon: Icons.http_outlined), + AppChoiceOption(value: 'https', label: 'HTTPS', icon: Icons.https_outlined), + AppChoiceOption(value: 'socks5', label: 'SOCKS5', icon: Icons.security_outlined), + ], + onChanged: (value) => _mutateConfig((next) => next.proxy.scheme = value), + ), + ), + SettingsItem( + title: context.l10n.server, + child: _HostPortControl( + hostController: _proxyHostController, + portController: _proxyPortController, + hostKey: const ValueKey('proxy-host-input'), + portKey: const ValueKey('proxy-port-input'), + ), + ), + SettingsItem( + title: context.l10n.username, + child: _TextSettingControl(controller: _proxyUserController), + ), + SettingsItem( + title: context.l10n.password, + child: _TextSettingControl(controller: _proxyPasswordController, obscureText: true), + ), + ], + SettingsItem( + title: context.l10n.githubMirrorEnable, + subtitle: context.l10n.githubMirrorDesc, + child: shad.Switch( + value: config.extra.githubMirror.enabled, + onChanged: (value) => _mutateConfig((next) => next.extra.githubMirror.enabled = value), + ), + ), + SettingsItem( + title: context.l10n.githubMirror, + child: SettingsListEditor( + entries: [ + for (final entry in config.extra.githubMirror.mirrors.indexed) + if (!entry.$2.isDeleted) + SettingsListEntry( + id: 'github-mirror-${entry.$1}', + title: entry.$2.url, + subtitle: entry.$2.type.name, + ), + ], + onAdd: () => unawaited(_editGithubMirror()), + onEdit: (visibleIndex) => unawaited(_editGithubMirrorAtVisibleIndex(visibleIndex)), + onDelete: (visibleIndex) => unawaited(_deleteGithubMirrorAtVisibleIndex(visibleIndex)), + ), + ), + ], + ), + ), + _SettingsBlock( + title: 'API', + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.protocol, + subtitle: context.l10n.restartRequired, + child: AppChoiceSegmentedControl( + value: _apiNetworkValue, + buttonKeyPrefix: 'settings-choice', + options: [ + const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined), + if (Util.supportUnixSocket()) + const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined), + ], + onChanged: _changeApiNetwork, + ), + ), + if (_apiNetworkValue == 'unix') + SettingsItem( + title: context.l10n.socketPath, + subtitle: context.l10n.restartRequired, + child: _TextSettingControl(controller: _apiAddressController), + ) + else + SettingsItem( + title: context.l10n.listenAddress, + subtitle: context.l10n.restartRequired, + child: _HostPortControl( + hostController: _apiHostController, + portController: _apiPortController, + hostKey: const ValueKey('api-host-input'), + portKey: const ValueKey('api-port-input'), + ), + ), + if (Util.isDesktop() && _apiNetworkValue == 'tcp') + SettingsItem( + title: context.l10n.apiToken, + child: _TextSettingControl(controller: _apiTokenController, obscureText: true), + ), + SettingsItem( + title: context.l10n.currentConnection, + subtitle: runtimeState == null + ? context.l10n.readingRuntimeStatus + : '${runtimeState.startConfig.network}://${runtimeState.runningAddress()}', + child: AppLoadingButton( + key: const ValueKey('save-api-config-button'), + onPressed: runtimeState == null || !apiConfigDirty ? null : _saveStartConfig, + loading: _savingStartConfig, + icon: const Icon(Icons.save_outlined), + child: Text(context.l10n.save), + ), + ), + ], + ), + ), + _SettingsBlock( + title: context.l10n.developer, + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.webhookEnable, + subtitle: context.l10n.webhookDesc, + child: shad.Switch( + value: config.webhook.enable, + onChanged: (value) => _mutateConfig((next) => next.webhook.enable = value), + ), + ), + SettingsItem( + title: context.l10n.webhookPush, + subtitle: context.l10n.webhookPushDescription, + child: SettingsListEditor( + entries: [ + for (final entry in config.webhook.urls.indexed) + SettingsListEntry(id: 'webhook-${entry.$1}', title: entry.$2), + ], + onAdd: () => unawaited(_editWebhook()), + onEdit: (index) => unawaited(_editWebhook(index)), + onDelete: _deleteWebhook, + addButtonKey: const ValueKey('add-webhook-button'), + ), + ), + if (Util.isDesktop()) ...[ + SettingsItem( + title: context.l10n.scriptEnable, + subtitle: context.l10n.scriptDesc, + child: shad.Switch( + value: config.script.enable, + onChanged: (value) => _mutateConfig((next) => next.script.enable = value), + ), + ), + SettingsItem( + title: context.l10n.scriptExecution, + subtitle: context.l10n.scriptExecutionDescription, + child: SettingsListEditor( + entries: [ + for (final entry in config.script.paths.indexed) + SettingsListEntry(id: 'script-${entry.$1}', title: entry.$2), + ], + onAdd: () => unawaited(_editScript()), + onEdit: (index) => unawaited(_editScript(index)), + onDelete: _deleteScript, + addButtonKey: const ValueKey('add-script-button'), + ), + ), + ], + SettingsItem( + title: context.l10n.logDirectory, + subtitle: _logDirectoryLabel, + child: shad.SecondaryButton( + onPressed: kIsWeb ? null : _openLogDirectory, + leading: const Icon(Icons.folder_open_outlined), + child: Text(context.l10n.open), + ), + ), + ], + ), + ), + ], + ); + } + } + + void _syncConfig(DownloaderConfig config) { + final signature = _signature(config); + if (_loadedSignature == signature && _config != null) { + return; + } + _loadedSignature = signature; + _config = DownloaderConfig.fromJson(config.toJson()); + _syncingControllers = true; + _downloadDirController.text = _config!.downloadDir; + _maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString(); + _httpUserAgentController.text = _config!.protocolConfig.http.userAgent; + _httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString(); + _btListenPortController.text = _config!.protocolConfig.bt.listenPort.clamp(0, 65535).toString(); + _btSeedRatioController.text = _config!.protocolConfig.bt.seedRatio.toString(); + _btSeedTimeController.text = (_config!.protocolConfig.bt.seedTime ~/ 60).clamp(0, 100000000).toString(); + _ed2kListenPortController.text = _config!.protocolConfig.ed2k.listenPort.clamp(0, 65535).toString(); + _ed2kUdpPortController.text = _config!.protocolConfig.ed2k.udpPort.clamp(0, 65535).toString(); + _ed2kServerAddrController.text = _formatEd2kMultiline(_config!.protocolConfig.ed2k.serverAddr); + _ed2kServerMetController.text = _formatEd2kMultiline(_config!.protocolConfig.ed2k.serverMet); + _ed2kNodesDatController.text = _formatEd2kMultiline(_config!.protocolConfig.ed2k.nodesDat); + final proxyAddress = _splitHostPort(_config!.proxy.host); + _proxyHostController.text = proxyAddress.host; + _proxyPortController.text = proxyAddress.port; + _proxyUserController.text = _config!.proxy.usr; + _proxyPasswordController.text = _config!.proxy.pwd; + _customTrackersController.text = _config!.extra.bt.customTrackers.join('\n'); + _syncingControllers = false; + } + + void _syncStartConfig(StartConfig config, {bool force = false}) { + final signature = _startSignature(config); + if (!force && _loadedStartSignature == signature && _startConfig != null) { + return; + } + _loadedStartSignature = signature; + _startConfig = _copyStartConfig(config); + _apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp'; + _syncingStartControllers = true; + _apiAddressController.text = _startConfig!.address; + if (_apiNetworkDraft == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) { + final apiAddress = _splitHostPort(_startConfig!.address); + _apiHostController.text = apiAddress.host; + _apiPortController.text = apiAddress.port; + } else { + _apiHostController.text = '127.0.0.1'; + _apiPortController.text = '9999'; + } + _apiTokenController.text = _startConfig!.apiToken; + _syncingStartControllers = false; + } + + void _scheduleTextSave() { + if (_syncingControllers || _config == null) { + return; + } + _textSaveTimer?.cancel(); + _textSaveTimer = Timer(const Duration(milliseconds: 650), () { + _applyTextControllers(_config!); + unawaited(_saveConfig()); + }); + } + + void _handleStartDraftChanged() { + if (_syncingStartControllers || !mounted) return; + setState(() {}); + } + + void _mutateConfig(void Function(DownloaderConfig config) mutation) { + final config = _config; + if (config == null) { + return; + } + setState(() => mutation(config)); + unawaited(_saveConfig()); + } + + void _applyTextControllers(DownloaderConfig config) { + config.downloadDir = _downloadDirController.text.trim(); + config.maxRunning = _boundedInt(_maxRunningController, fallback: config.maxRunning, min: 1, max: 256); + config.protocolConfig.http.userAgent = _httpUserAgentController.text.trim(); + config.protocolConfig.http.connections = _boundedInt( + _httpConnectionsController, + fallback: config.protocolConfig.http.connections, + min: 1, + max: 256, + ); + config.protocolConfig.bt.listenPort = _boundedInt( + _btListenPortController, + fallback: config.protocolConfig.bt.listenPort, + min: 0, + max: 65535, + ); + config.protocolConfig.bt.seedRatio = + (double.tryParse(_btSeedRatioController.text.trim()) ?? config.protocolConfig.bt.seedRatio) + .clamp(0, double.infinity) + .toDouble(); + final seedMinutes = _boundedInt( + _btSeedTimeController, + fallback: config.protocolConfig.bt.seedTime ~/ 60, + min: 0, + max: 100000000, + ); + config.protocolConfig.bt.seedTime = seedMinutes * 60; + config.protocolConfig.ed2k.listenPort = _boundedInt( + _ed2kListenPortController, + fallback: config.protocolConfig.ed2k.listenPort, + min: 0, + max: 65535, + ); + config.protocolConfig.ed2k.udpPort = _boundedInt( + _ed2kUdpPortController, + fallback: config.protocolConfig.ed2k.udpPort, + min: 0, + max: 65535, + ); + config.protocolConfig.ed2k.serverAddr = _lines(_ed2kServerAddrController.text).join(','); + config.protocolConfig.ed2k.serverMet = _lines(_ed2kServerMetController.text).join(','); + config.protocolConfig.ed2k.nodesDat = _lines(_ed2kNodesDatController.text).join(','); + config.proxy.host = _joinHostPort(_proxyHostController.text, _proxyPortController.text); + config.proxy.usr = _proxyUserController.text.trim(); + config.proxy.pwd = _proxyPasswordController.text; + config.extra.bt.customTrackers = _lines(_customTrackersController.text); + config.protocolConfig.bt.trackers = { + ...config.extra.bt.subscribeTrackers, + ...config.extra.bt.customTrackers, + }.toList(); + } + + void _applyStartControllers(StartConfig config) { + config.network = _apiNetworkDraft ?? config.network; + config.address = config.network == 'unix' + ? _apiAddressController.text.trim() + : _joinHostPort(_apiHostController.text, _apiPortController.text); + config.apiToken = _apiTokenController.text.trim(); + } + + Future _saveConfig() async { + final config = _config; + if (config == null) { + return; + } + _applyTextControllers(config); + final snapshot = DownloaderConfig.fromJson(config.toJson()); + _loadedSignature = _signature(snapshot); + try { + await ref.read(settingsControllerProvider.notifier).save(snapshot); + } catch (error) { + if (mounted) _toast(error.toString()); + } + } + + Future _saveStartConfig() async { + final config = _startConfig; + final runtimeConfig = ref.read(appRuntimeControllerProvider).value?.startConfig; + if (config == null || runtimeConfig == null || _savingStartConfig) { + return; + } + final snapshot = _copyStartConfig(config); + _applyStartControllers(snapshot); + setState(() => _savingStartConfig = true); + try { + final validationMessage = await _validateStartConfig(snapshot, runtimeConfig); + if (validationMessage != null) { + if (mounted) _toast(validationMessage); + return; + } + await ref.read(appRuntimeControllerProvider.notifier).saveStartConfig(snapshot); + _startConfig = snapshot; + _apiNetworkDraft = snapshot.network; + if (mounted) _toast(context.l10n.apiConfigSaved, type: AppToastType.success); + } catch (error) { + if (mounted) _toast(error.toString()); + } finally { + if (mounted) setState(() => _savingStartConfig = false); + } + } + + Future _validateStartConfig(StartConfig candidate, StartConfig current) async { + final l10n = context.l10n; + if (candidate.network == 'unix') { + return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null; + } + + final address = _splitHostPort(candidate.address); + if (address.host.isEmpty) return l10n.serverRequired; + final port = int.tryParse(address.port); + if (port == null || port < 0 || port > 65535) return l10n.portRangeError; + + final addressChanged = current.network != candidate.network || current.address != candidate.address; + if (addressChanged && port != 0 && await isTcpPortInUse(address.host, port)) { + return l10n.portInUseValue(port); + } + return null; + } + + void _changeApiNetwork(String value) { + final config = _startConfig; + if (config == null || _apiNetworkDraft == value) return; + setState(() { + _apiNetworkDraft = value; + _syncingStartControllers = true; + if (value == 'tcp' && (_apiHostController.text.isEmpty || _apiPortController.text.isEmpty)) { + if (_looksLikeTcpAddress(config.address)) { + final address = _splitHostPort(config.address); + _apiHostController.text = address.host; + _apiPortController.text = address.port; + } else { + _apiHostController.text = '127.0.0.1'; + _apiPortController.text = '9999'; + } + } + if (value == 'unix' && (_apiAddressController.text.isEmpty || _looksLikeTcpAddress(_apiAddressController.text))) { + _apiAddressController.text = '${Util.getStorageDir()}/$unixSocketPath'; + } + _syncingStartControllers = false; + }); + } + + Future _runAction(Future Function() action) async { + try { + await action(); + } catch (error) { + if (mounted) _toast(error.toString()); + } + } + + Future _setDefaultBtClient(bool enabled) async { + try { + enabled ? registerDefaultTorrentClient() : unregisterDefaultTorrentClient(); + _mutateConfig((config) => config.extra.defaultBtClient = enabled); + } catch (error) { + if (mounted) _toast(error.toString()); + } + } + + Future _setBackgroundLocationKeepAlive(bool enabled) async { + if (enabled && !await LocationKeepAlive.requestPermission()) { + if (mounted) _toast(context.l10n.backgroundLocationPermissionRequired); + return; + } + _mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled); + await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled); + } + + List _lines(String text) { + return text.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); + } + + String _formatEd2kMultiline(String value) { + return value.split(RegExp(r'[,\r\n]+')).map((entry) => entry.trim()).where((entry) => entry.isNotEmpty).join('\n'); + } + + String _categoryName(DownloadCategory category) { + if (category.name.isNotEmpty) { + return category.name; + } + return switch (category.nameKey) { + 'categoryMusic' => context.l10n.categoryMusic, + 'categoryVideo' => context.l10n.categoryVideo, + 'categoryDocument' => context.l10n.categoryDocument, + 'categoryProgram' => context.l10n.categoryProgram, + _ => context.l10n.downloadCategories, + }; + } + + Future _editDownloadCategory([DownloadCategory? category]) async { + final draft = await showDownloadCategoryDialog( + context, + category: category, + initialName: category == null ? '' : _categoryName(category), + initialPath: _downloadDirController.text.trim(), + ); + if (draft == null || !mounted) return; + + _mutateConfig((config) { + final categories = List.from(config.extra.downloadCategories); + if (category == null) { + categories.add(DownloadCategory(name: draft.name, path: draft.path)); + } else { + final index = categories.indexOf(category); + if (index < 0) return; + final nameChanged = draft.name != _categoryName(category); + category + ..name = draft.name + ..path = draft.path + ..isDeleted = false; + if (nameChanged) category.nameKey = null; + } + config.extra.downloadCategories = categories; + }); + } + + Future _deleteDownloadCategory(DownloadCategory category) async { + final confirmed = await showDeleteDownloadCategoryDialog(context, _categoryName(category)); + if (!confirmed || !mounted) return; + _mutateConfig((config) { + if (category.isBuiltIn) { + category.isDeleted = true; + return; + } + config.extra.downloadCategories = List.from(config.extra.downloadCategories)..remove(category); + }); + } + + List get _visibleGithubMirrors => + _config?.extra.githubMirror.mirrors.where((mirror) => !mirror.isDeleted).toList(growable: false) ?? const []; + + Future _editGithubMirror([GithubMirror? mirror]) async { + final draft = await showGithubMirrorDialog(context, mirror: mirror); + if (draft == null || !mounted) return; + _mutateConfig((config) { + final mirrors = List.from(config.extra.githubMirror.mirrors); + if (mirror == null) { + mirrors.add(GithubMirror(type: draft.type, url: draft.url)); + } else { + final index = mirrors.indexOf(mirror); + if (index < 0) return; + mirrors[index] = GithubMirror(type: draft.type, url: draft.url, isBuiltIn: mirror.isBuiltIn, isDeleted: false); + } + config.extra.githubMirror.mirrors = mirrors; + }); + } + + Future _editGithubMirrorAtVisibleIndex(int index) async { + final mirrors = _visibleGithubMirrors; + if (index < 0 || index >= mirrors.length) return; + await _editGithubMirror(mirrors[index]); + } + + Future _deleteGithubMirrorAtVisibleIndex(int index) async { + final mirrors = _visibleGithubMirrors; + if (index < 0 || index >= mirrors.length) return; + final mirror = mirrors[index]; + if (!await showDeleteSettingsEntryDialog(context, mirror.url) || !mounted) return; + _mutateConfig((config) { + if (mirror.isBuiltIn) { + mirror.isDeleted = true; + } else { + config.extra.githubMirror.mirrors = List.from(config.extra.githubMirror.mirrors)..remove(mirror); + } + }); + } + + Future _editWebhook([int? index]) async { + final urls = _config?.webhook.urls ?? const []; + if (index != null && (index < 0 || index >= urls.length)) return; + final value = await showTextSettingDialog( + context, + title: index == null ? context.l10n.addWebhook : context.l10n.editWebhook, + fieldLabel: context.l10n.webhookUrlHint, + initialValue: index == null ? '' : urls[index], + requireHttpUrl: true, + onTest: (url) => ref.read(settingsControllerProvider.notifier).testWebhook(url), + ); + if (value == null || !mounted) return; + _mutateConfig((config) { + final next = List.from(config.webhook.urls); + if (index == null) { + next.add(value); + } else { + next[index] = value; + } + config.webhook.urls = next; + }); + } + + void _deleteWebhook(int index) { + _mutateConfig((config) { + if (index < 0 || index >= config.webhook.urls.length) return; + config.webhook.urls = List.from(config.webhook.urls)..removeAt(index); + }); + } + + Future _editScript([int? index]) async { + final paths = _config?.script.paths ?? const []; + if (index != null && (index < 0 || index >= paths.length)) return; + final value = await showTextSettingDialog( + context, + title: index == null ? context.l10n.addScript : context.l10n.editScript, + fieldLabel: context.l10n.scriptPath, + initialValue: index == null ? '' : paths[index], + pickPath: Util.isDesktop() + ? () async { + final result = await FilePicker.platform.pickFiles(); + return result?.files.firstOrNull?.path; + } + : null, + ); + if (value == null || !mounted) return; + _mutateConfig((config) { + final next = List.from(config.script.paths); + if (index == null) { + next.add(value); + } else { + next[index] = value; + } + config.script.paths = next; + }); + } + + void _deleteScript(int index) { + _mutateConfig((config) { + if (index < 0 || index >= config.script.paths.length) return; + config.script.paths = List.from(config.script.paths)..removeAt(index); + }); + } + + Future _updateTrackers() async { + final config = _config; + if (config == null) return; + await _runAction(() async { + await ref.read(appRuntimeControllerProvider.notifier).updateTrackers(DownloaderConfig.fromJson(config.toJson())); + if (mounted) await ref.read(settingsControllerProvider.notifier).reload(showLoading: false); + }); + } + + String _signature(DownloaderConfig config) { + return config.toJson().toString(); + } + + String _startSignature(StartConfig config) { + return '${config.network}|${config.address}|${config.apiToken}'; + } + + String _startDraftSignature() { + final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp'; + final address = network == 'unix' + ? _apiAddressController.text.trim() + : _joinHostPort(_apiHostController.text, _apiPortController.text); + return '$network|$address|${_apiTokenController.text.trim()}'; + } + + StartConfig _copyStartConfig(StartConfig config) { + return StartConfig() + ..network = config.network + ..address = config.address + ..storage = config.storage + ..storageDir = config.storageDir + ..refreshInterval = config.refreshInterval + ..apiToken = config.apiToken + ..webViewRpcConfig = config.webViewRpcConfig; + } + + String get _apiNetworkValue { + final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp'; + if (network == 'unix' && Util.supportUnixSocket()) { + return 'unix'; + } + return 'tcp'; + } + + String get _proxyMode { + final proxy = _config?.proxy; + if (proxy == null || !proxy.enable) return 'none'; + return proxy.system ? 'system' : 'custom'; + } + + String get _proxyScheme { + final scheme = _config?.proxy.scheme; + return switch (scheme) { + 'https' || 'socks5' => scheme!, + _ => 'http', + }; + } + + String get _versionLabel { + try { + return appVersion; + } catch (_) { + return '-'; + } + } + + String get _logDirectoryLabel { + try { + return logsDir(); + } catch (_) { + return context.l10n.logDirectoryUnavailable; + } + } + + void _openLogDirectory() { + try { + unawaited(launchUrl(Uri.file(logsDir()), mode: LaunchMode.externalApplication)); + } catch (error) { + _toast(error.toString()); + } + } + + Future _openBrowserExtension(Uri uri) async { + await _openExternalUri(uri); + } + + Future _openExternalUri(Uri uri) async { + try { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!opened && mounted) { + _toast(context.l10n.unableOpenExternalLink); + } + } catch (error) { + if (mounted) _toast(error.toString()); + } + } + + bool _looksLikeTcpAddress(String address) { + final separator = address.lastIndexOf(':'); + if (separator <= 0 || separator == address.length - 1) { + return false; + } + return int.tryParse(address.substring(separator + 1)) != null; + } + + ({String host, String port}) _splitHostPort(String address) { + final value = address.trim(); + if (value.isEmpty) return (host: '', port: ''); + + // Bracketed IPv6 addresses keep their internal colons, for example [::1]:9999. + if (value.startsWith('[')) { + final closingBracket = value.indexOf(']'); + if (closingBracket > 0 && closingBracket + 1 < value.length && value[closingBracket + 1] == ':') { + return (host: value.substring(0, closingBracket + 1), port: value.substring(closingBracket + 2)); + } + } + + final separator = value.lastIndexOf(':'); + if (separator <= 0 || separator == value.length - 1) { + return (host: value, port: ''); + } + return (host: value.substring(0, separator), port: value.substring(separator + 1)); + } + + String _joinHostPort(String hostText, String portText) { + final host = hostText.trim(); + final port = portText.trim(); + if (host.isEmpty && port.isEmpty) return ''; + if (port.isEmpty) return host; + return '$host:$port'; + } + + int _boundedInt(TextEditingController controller, {required int fallback, required int min, required int max}) { + final value = int.tryParse(controller.text.trim()); + return (value ?? fallback).clamp(min, max).toInt(); + } + + String _sectionTitle(_SettingsSection section) { + return switch (section) { + _SettingsSection.basic => context.l10n.basicSettings, + _SettingsSection.downloads => context.l10n.downloadSettings, + _SettingsSection.advanced => context.l10n.advancedSettings, + }; + } + + String _languageValue(DownloaderConfig config) { + final locale = supportedLocaleFromConfig(config.extra.locale); + return locale == null ? 'system' : localeConfigValue(locale); + } + + void _toast(String message, {AppToastType type = AppToastType.error}) { + showAppToast(context, message, type: type); + } +} + +class _SettingsGroup extends StatelessWidget { + const _SettingsGroup({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: children.length, + separatorBuilder: (_, _) => + Container(height: 1, margin: const EdgeInsets.symmetric(vertical: 12), color: palette.border), + itemBuilder: (context, index) => children[index], + ), + ); + } +} + +class _SettingsSectionStack extends StatelessWidget { + const _SettingsSectionStack({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const SizedBox(height: 18), + children[index], + ], + ], + ); + } +} + +class _SettingsBlock extends StatelessWidget { + const _SettingsBlock({super.key, required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Text( + title, + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + child, + ], + ); + } +} + +class _TextSettingControl extends StatelessWidget { + const _TextSettingControl({ + required this.controller, + this.fieldKey, + this.hintText, + this.minLines = 1, + this.obscureText = false, + }); + + final TextEditingController controller; + final Key? fieldKey; + final String? hintText; + final int minLines; + final bool obscureText; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + return SizedBox( + width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, + child: shad.TextField( + key: fieldKey, + controller: controller, + hintText: hintText, + keyboardType: minLines > 1 ? TextInputType.multiline : null, + obscureText: obscureText, + minLines: minLines, + maxLines: minLines == 1 ? 1 : minLines, + ), + ); + } +} + +class _NumberSettingControl extends StatelessWidget { + const _NumberSettingControl({ + required this.controller, + required this.min, + this.fieldKey, + this.max, + this.step = 1, + this.decimalPlaces = 0, + }); + + final TextEditingController controller; + final num min; + final Key? fieldKey; + final num? max; + final double step; + final int decimalPlaces; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + return SizedBox( + width: desktop ? AppDesignTokens.settingsNumberControlWidth : double.infinity, + child: _NumberInput( + fieldKey: fieldKey, + controller: controller, + min: min, + max: max, + step: step, + decimalPlaces: decimalPlaces, + ), + ); + } +} + +class _HostPortControl extends StatelessWidget { + const _HostPortControl({ + required this.hostController, + required this.portController, + required this.hostKey, + required this.portKey, + }); + + final TextEditingController hostController; + final TextEditingController portController; + final Key hostKey; + final Key portKey; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + return SizedBox( + width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, + child: Row( + children: [ + Expanded( + child: shad.TextField(key: hostKey, controller: hostController, hintText: context.l10n.server), + ), + const SizedBox(width: AppDesignTokens.space8), + SizedBox( + width: AppDesignTokens.settingsNumberControlWidth, + child: _NumberInput( + fieldKey: portKey, + controller: portController, + min: 0, + max: 65535, + step: 1, + decimalPlaces: 0, + hintText: context.l10n.port, + ), + ), + ], + ), + ); + } +} + +class _NumberInput extends StatefulWidget { + const _NumberInput({ + required this.controller, + required this.min, + required this.step, + required this.decimalPlaces, + this.fieldKey, + this.max, + this.hintText, + }); + + final TextEditingController controller; + final num min; + final num? max; + final double step; + final int decimalPlaces; + final Key? fieldKey; + final String? hintText; + + @override + State<_NumberInput> createState() => _NumberInputState(); +} + +class _NumberInputState extends State<_NumberInput> { + bool _normalizing = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_normalizeDecimalStepValue); + } + + @override + void didUpdateWidget(covariant _NumberInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller == widget.controller) return; + oldWidget.controller.removeListener(_normalizeDecimalStepValue); + widget.controller.addListener(_normalizeDecimalStepValue); + } + + @override + void dispose() { + widget.controller.removeListener(_normalizeDecimalStepValue); + super.dispose(); + } + + void _normalizeDecimalStepValue() { + if (_normalizing || widget.decimalPlaces <= 0) return; + final text = widget.controller.text; + final separator = text.indexOf('.'); + if (separator < 0 || text.length - separator - 1 <= widget.decimalPlaces) return; + final value = double.tryParse(text); + if (value == null) return; + + var normalized = value.toStringAsFixed(widget.decimalPlaces); + normalized = normalized.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), ''); + if (normalized == text) return; + + _normalizing = true; + widget.controller.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + _normalizing = false; + } + + @override + Widget build(BuildContext context) { + final formatters = widget.decimalPlaces > 0 + ? [_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)] + : [ + FilteringTextInputFormatter.digitsOnly, + _NumericalRangeFormatter(min: widget.min.toInt(), max: widget.max?.toInt()), + ]; + return shad.TextField( + key: widget.fieldKey, + controller: widget.controller, + hintText: widget.hintText, + keyboardType: widget.decimalPlaces > 0 + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, + inputFormatters: formatters, + features: [ + shad.InputFeature.spinner( + step: widget.step, + min: widget.min.toDouble(), + max: widget.max?.toDouble(), + invalidValue: widget.min.toDouble(), + enableGesture: false, + ), + ], + ); + } +} + +/// Keeps integer text within the same inclusive bounds as the legacy settings UI. +/// Empty text is allowed while editing; the previous valid value remains in config +/// until the user enters a number again. +class _NumericalRangeFormatter extends TextInputFormatter { + const _NumericalRangeFormatter({required this.min, this.max}); + + final int min; + final int? max; + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + if (newValue.text.isEmpty) return newValue; + final parsed = int.tryParse(newValue.text); + if (parsed == null) return oldValue; + final bounded = parsed.clamp(min, max ?? parsed).toInt(); + if (bounded == parsed) return newValue; + final text = bounded.toString(); + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } +} + +/// Allows a non-negative decimal number with a bounded number of fractional digits. +class _DecimalNumberFormatter extends TextInputFormatter { + _DecimalNumberFormatter({required this.decimalPlaces}) : _pattern = RegExp('^\\d*(?:\\.\\d{0,$decimalPlaces})?\$'); + + final int decimalPlaces; + final RegExp _pattern; + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + return _pattern.hasMatch(newValue.text) ? newValue : oldValue; + } +} + +class _ExternalTextLink extends StatefulWidget { + const _ExternalTextLink({super.key, required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + State<_ExternalTextLink> createState() => _ExternalTextLinkState(); +} + +class _ExternalTextLinkState extends State<_ExternalTextLink> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final color = _hovered ? palette.brandProgress : palette.brand; + return Semantics( + link: true, + label: widget.label, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + decorationColor: color, + decorationThickness: _hovered ? 1.5 : 1, + ), + ), + ), + const SizedBox(width: 5), + Icon(Icons.open_in_new, size: 13, color: color), + ], + ), + ), + ), + ), + ); + } +} + +class _BrowserExtensionLinks extends StatelessWidget { + const _BrowserExtensionLinks({required this.onOpen}); + + static final _links = <({String label, Uri uri})>[ + ( + label: 'Chrome', + uri: Uri.parse('https://chromewebstore.google.com/detail/gopeed/mijpgljlfcapndmchhjffkpckknofcnd'), + ), + ( + label: 'Edge', + uri: Uri.parse('https://microsoftedge.microsoft.com/addons/detail/dkajnckekendchdleoaenoophcobooce'), + ), + (label: 'Firefox', uri: Uri.parse('https://addons.mozilla.org/firefox/addon/gopeed-extension/')), + ]; + + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: desktop ? WrapAlignment.end : WrapAlignment.start, + children: [ + for (final link in _links) + shad.SecondaryButton( + key: ValueKey('browser-extension-${link.label.toLowerCase()}'), + onPressed: () => onOpen(link.uri), + leading: const Icon(Icons.open_in_new, size: 15), + child: Text(link.label), + ), + ], + ); + } +} + +class _TrackerSubscriptionsControl extends StatefulWidget { + const _TrackerSubscriptionsControl({ + required this.selected, + required this.autoUpdate, + required this.lastUpdated, + required this.onChanged, + required this.onAutoUpdateChanged, + required this.onUpdate, + }); + + final List selected; + final bool autoUpdate; + final DateTime? lastUpdated; + final ValueChanged> onChanged; + final ValueChanged onAutoUpdateChanged; + final Future Function() onUpdate; + + @override + State<_TrackerSubscriptionsControl> createState() => _TrackerSubscriptionsControlState(); +} + +class _TrackerSubscriptionsControlState extends State<_TrackerSubscriptionsControl> { + final _verticalScrollController = ScrollController(); + final _horizontalScrollController = ScrollController(); + bool _updating = false; + + @override + void dispose() { + _verticalScrollController.dispose(); + _horizontalScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + final palette = AppPalette.of(context); + final selectedCount = allTrackerSubscribeUrls.where(widget.selected.contains).length; + final allSelected = selectedCount == allTrackerSubscribeUrls.length; + final selectAllState = allSelected + ? shad.CheckboxState.checked + : selectedCount == 0 + ? shad.CheckboxState.unchecked + : shad.CheckboxState.indeterminate; + return SizedBox( + width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 210, + decoration: BoxDecoration( + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Column( + children: [ + GestureDetector( + key: const ValueKey('tracker-select-all'), + behavior: HitTestBehavior.opaque, + onTap: () => _toggleAll(allSelected), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + shad.Checkbox(state: selectAllState, onChanged: (_) => _toggleAll(allSelected)), + const SizedBox(width: AppDesignTokens.checkboxLabelGap), + Expanded( + child: Text( + context.l10n.selectAll, + style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + Text( + '$selectedCount/${allTrackerSubscribeUrls.length}', + style: TextStyle(color: palette.textMuted, fontSize: 11), + ), + ], + ), + ), + ), + Divider(height: 1, color: palette.border), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final labelStyle = TextStyle(color: palette.textSecondary, fontSize: 11); + final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp( + 0.0, + double.infinity, + ); + return Scrollbar( + key: const ValueKey('tracker-horizontal-scrollbar'), + controller: _horizontalScrollController, + thumbVisibility: true, + scrollbarOrientation: ScrollbarOrientation.bottom, + notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(bottom: AppDesignTokens.space12), + child: SingleChildScrollView( + key: const ValueKey('tracker-horizontal-scroll-view'), + controller: _horizontalScrollController, + scrollDirection: Axis.horizontal, + child: SizedBox( + height: contentHeight, + child: Scrollbar( + key: const ValueKey('tracker-vertical-scrollbar'), + controller: _verticalScrollController, + thumbVisibility: true, + scrollbarOrientation: ScrollbarOrientation.right, + notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical, + child: SingleChildScrollView( + key: const ValueKey('tracker-vertical-scroll-view'), + controller: _verticalScrollController, + child: IntrinsicWidth( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (index, url) in allTrackerSubscribeUrls.indexed) ...[ + Builder( + builder: (context) { + final checked = widget.selected.contains(url); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _toggle(url, checked), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + shad.Checkbox( + state: checked + ? shad.CheckboxState.checked + : shad.CheckboxState.unchecked, + onChanged: (_) => _toggle(url, checked), + ), + const SizedBox(width: AppDesignTokens.checkboxLabelGap), + Text(url, maxLines: 1, style: labelStyle), + ], + ), + ), + ); + }, + ), + if (index != allTrackerSubscribeUrls.length - 1) + Divider(height: 1, color: palette.border), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + children: [ + AppLoadingButton( + key: const ValueKey('tracker-update-button'), + onPressed: _update, + loading: _updating, + icon: const Icon(Icons.refresh, size: 17), + child: Text(context.l10n.newVersionUpdate), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context.l10n.updateEveryDay, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + ), + const SizedBox(width: 8), + shad.Switch(value: widget.autoUpdate, onChanged: widget.onAutoUpdateChanged), + ], + ), + const SizedBox(height: 6), + Text( + widget.lastUpdated == null + ? context.l10n.neverUpdated + : context.l10n.lastUpdated(_formatDateTime(widget.lastUpdated!)), + style: TextStyle(color: palette.textMuted, fontSize: 11), + ), + ], + ), + ); + } + + void _toggle(String url, bool checked) { + final next = List.from(widget.selected); + checked ? next.remove(url) : next.add(url); + widget.onChanged(next); + } + + void _toggleAll(bool allSelected) { + widget.onChanged(allSelected ? const [] : List.from(allTrackerSubscribeUrls)); + } + + Future _update() async { + setState(() => _updating = true); + try { + await widget.onUpdate(); + } finally { + if (mounted) setState(() => _updating = false); + } + } + + String _formatDateTime(DateTime value) { + String two(int number) => number.toString().padLeft(2, '0'); + return '${value.year}-${two(value.month)}-${two(value.day)} ${two(value.hour)}:${two(value.minute)}:${two(value.second)}'; + } +} + +class _UpdateActionControl extends ConsumerWidget { + const _UpdateActionControl({required this.platform}); + + final AppPlatformState? platform; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = platform?.updateStatus ?? AppUpdateStatus.idle; + final latest = platform?.latestVersion; + final checking = status == AppUpdateStatus.checking; + + Future handlePressed() async { + var nextVersion = latest; + if (status != AppUpdateStatus.available || nextVersion == null) { + nextVersion = await ref.read(appPlatformControllerProvider.notifier).checkForUpdate(); + } + if (!context.mounted || nextVersion == null) return; + await showAppUpdateDialog( + context, + versionInfo: nextVersion, + onUpdate: ref.read(appPlatformControllerProvider.notifier).startUpdate, + ); + } + + return _UpdateActionButton( + available: status == AppUpdateStatus.available, + checking: checking, + onPressed: () => unawaited(handlePressed()), + ); + } +} + +class _UpdateActionButton extends StatelessWidget { + const _UpdateActionButton({required this.available, required this.checking, required this.onPressed}); + + final bool available; + final bool checking; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return AppLoadingButton( + key: const ValueKey('check-app-update-button'), + onPressed: onPressed, + loading: checking, + icon: Icon(available ? Icons.system_update_alt_outlined : Icons.refresh, size: 17), + variant: available ? AppLoadingButtonVariant.brand : AppLoadingButtonVariant.secondary, + child: Text(available ? context.l10n.updateVersion : context.l10n.checkForUpdates), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.settings_outlined, size: 32, color: palette.error), + const SizedBox(height: 12), + Text( + context.l10n.unableLoadSettings, + style: TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + error.toString(), + textAlign: TextAlign.center, + style: TextStyle(color: palette.textSecondary), + ), + const SizedBox(height: 16), + shad.SecondaryButton(onPressed: onRetry, child: Text(context.l10n.retry)), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart new file mode 100644 index 00000000..7367c609 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../util/package_info.dart'; +import '../../../../util/updater.dart'; +import '../../../../util/util.dart'; +import '../../../../l10n/l10n.dart'; + +typedef StartAppUpdate = Future Function(VersionInfo versionInfo, UpdateProgressCallback onProgress); + +Future showAppUpdateDialog( + BuildContext context, { + required VersionInfo versionInfo, + required StartAppUpdate onUpdate, +}) async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) { + var updating = false; + var received = 0; + var total = 0; + String? errorMessage; + + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + final availableWidth = MediaQuery.sizeOf(dialogContext).width - 104; + final dialogWidth = availableWidth.clamp(260.0, 380.0); + final releaseNotes = localizedReleaseNotes( + versionInfo.changeLog, + Localizations.localeOf(dialogContext).languageCode, + ); + final progress = total > 0 ? (received / total).clamp(0.0, 1.0) : null; + + Future update() async { + setDialogState(() { + updating = true; + errorMessage = null; + }); + try { + await onUpdate(versionInfo, (nextReceived, nextTotal) { + if (!dialogContext.mounted) return; + setDialogState(() { + received = nextReceived; + total = nextTotal; + }); + }); + } catch (error) { + if (!dialogContext.mounted) return; + setDialogState(() { + updating = false; + errorMessage = dialogContext.l10n.updateFailed; + }); + } + } + + return shad.AlertDialog( + padding: const EdgeInsets.all(20), + title: SizedBox( + width: dialogWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + dialogContext.l10n.newVersionAvailable, + key: const ValueKey('app-update-heading'), + textAlign: TextAlign.center, + ), + const SizedBox(height: 3), + Text( + '$appVersion → ${versionInfo.version}', + textAlign: TextAlign.center, + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400), + ), + ], + ), + ), + content: SizedBox( + key: const ValueKey('app-update-dialog'), + width: dialogWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + dialogContext.l10n.releaseNotes, + style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 210), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _ReleaseNotes( + notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, + ), + ), + ), + if (updating) ...[ + const SizedBox(height: 16), + shad.LinearProgressIndicator( + value: progress, + minHeight: 5, + borderRadius: BorderRadius.circular(999), + color: palette.brand, + backgroundColor: palette.progressTrack, + ), + const SizedBox(height: 7), + Row( + children: [ + Text( + progress == null + ? dialogContext.l10n.preparingDownload + : '${(progress * 100).toStringAsFixed(0)}%', + style: TextStyle(color: palette.textSecondary, fontSize: 11), + ), + const Spacer(), + if (total > 0) + Text( + '${Util.fmtByte(received)} / ${Util.fmtByte(total)}', + style: TextStyle(color: palette.textSecondary, fontSize: 11), + ), + ], + ), + ], + if (errorMessage != null) ...[ + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 15, color: palette.error), + const SizedBox(width: 6), + Text(errorMessage!, style: TextStyle(color: palette.error, fontSize: 12)), + ], + ), + ], + const SizedBox(height: 18), + Wrap( + key: const ValueKey('app-update-actions'), + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + shad.SecondaryButton( + onPressed: updating ? null : () => unawaited(shad.closeOverlay(dialogContext)), + child: Text(dialogContext.l10n.newVersionLater), + ), + AppLoadingButton( + onPressed: () => unawaited(update()), + loading: updating, + variant: AppLoadingButtonVariant.primary, + icon: const Icon(Icons.system_update_alt_outlined, size: 17), + child: Text(dialogContext.l10n.newVersionUpdate), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + await overlay.future; +} + +class _ReleaseNotes extends StatelessWidget { + const _ReleaseNotes({required this.notes}); + + final String notes; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final lines = notes + .split('\n') + .map((line) => line.trimRight()) + .where((line) => line.trim().isNotEmpty) + .where((line) => !RegExp(r'^#\s+(Release notes|更新日志)\s*$', caseSensitive: false).hasMatch(line.trim())); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) ...[ + if (line.startsWith('#')) + Text( + _plainMarkdown(line.replaceFirst(RegExp(r'^#+\s*'), '')), + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ) + else if (line.trimLeft().startsWith('- ')) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('• ', style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.45)), + Expanded( + child: Text( + _plainMarkdown(line.trimLeft().substring(2).replaceFirst(RegExp(r'\s+@[\w-]+\s+\(#\d+\)\s*$'), '')), + style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.45), + ), + ), + ], + ) + else + Text(_plainMarkdown(line), style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.45)), + const SizedBox(height: 7), + ], + ], + ); + } +} + +String _plainMarkdown(String value) { + return value + .replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^\)]+\)'), (match) => match.group(1) ?? '') + .replaceAll(RegExp(r'\*\*|__|`'), ''); +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart new file mode 100644 index 00000000..e5112a2c --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart' show Divider, Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../api/model/downloader_config.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_path_picker_field.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../l10n/l10n.dart'; + +class DownloadCategoryDraft { + const DownloadCategoryDraft({required this.name, required this.path}); + + final String name; + final String path; +} + +class DownloadCategoriesControl extends StatelessWidget { + const DownloadCategoriesControl({ + super.key, + required this.categories, + required this.displayName, + required this.onAdd, + required this.onEdit, + required this.onDelete, + }); + + final List categories; + final String Function(DownloadCategory category) displayName; + final VoidCallback onAdd; + final ValueChanged onEdit; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + final visibleCategories = categories.where((category) => !category.isDeleted).toList(growable: false); + final palette = AppPalette.of(context); + + return SizedBox( + key: const ValueKey('download-categories-editor'), + width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < visibleCategories.length; index++) ...[ + if (index > 0) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(color: palette.border), + ), + _CategoryRow( + category: visibleCategories[index], + name: displayName(visibleCategories[index]), + onEdit: () => onEdit(visibleCategories[index]), + onDelete: () => onDelete(visibleCategories[index]), + ), + ], + if (visibleCategories.isNotEmpty) const SizedBox(height: 10), + Align( + alignment: desktop ? Alignment.centerRight : Alignment.centerLeft, + child: shad.SecondaryButton( + key: const ValueKey('add-download-category'), + onPressed: onAdd, + leading: const Icon(Icons.add, size: 17), + child: Text(context.l10n.add), + ), + ), + ], + ), + ); + } +} + +class _CategoryRow extends StatelessWidget { + const _CategoryRow({required this.category, required this.name, required this.onEdit, required this.onDelete}); + + final DownloadCategory category; + final String name; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + key: ValueKey('download-category-${category.nameKey ?? category.name}-${category.path}'), + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 3), + Text( + category.path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 8), + shad.GhostButton( + key: ValueKey('edit-download-category-${category.nameKey ?? category.name}'), + density: shad.ButtonDensity.icon, + onPressed: onEdit, + child: const Icon(Icons.edit_outlined, size: 17), + ), + shad.GhostButton( + key: ValueKey('delete-download-category-${category.nameKey ?? category.name}'), + density: shad.ButtonDensity.icon, + onPressed: onDelete, + child: const Icon(Icons.delete_outline, size: 17), + ), + ], + ); + } +} + +Future showDownloadCategoryDialog( + BuildContext context, { + DownloadCategory? category, + required String initialName, + required String initialPath, +}) async { + final nameController = TextEditingController(text: initialName); + final pathController = TextEditingController(text: category?.path ?? initialPath); + String? validationMessage; + + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + + void submit() { + final name = nameController.text.trim(); + final path = pathController.text.trim(); + if (name.isEmpty || path.isEmpty) { + setDialogState(() => validationMessage = dialogContext.l10n.categoryFieldsRequired); + return; + } + shad.closeOverlay(dialogContext, DownloadCategoryDraft(name: name, path: path)); + } + + return shad.AlertDialog( + title: Text(category == null ? dialogContext.l10n.addCategory : dialogContext.l10n.editCategory), + content: SizedBox( + key: const ValueKey('download-category-dialog'), + width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 420.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + shad.TextField(key: const ValueKey('download-category-name'), controller: nameController), + const SizedBox(height: 14), + Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + AppPathPickerField.downloadDirectory( + fieldKey: const ValueKey('download-category-path'), + controller: pathController, + desktopWidth: AppDesignTokens.settingsFormControlWidth, + allowAndroidEditing: true, + ), + if (validationMessage != null) ...[ + const SizedBox(height: 8), + Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)), + ], + ], + ), + ), + actions: [ + shad.SecondaryButton( + onPressed: () => shad.closeOverlay(dialogContext), + child: Text(dialogContext.l10n.cancel), + ), + AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.confirm)), + ], + ); + }, + ), + ); + + try { + return await overlay.future; + } finally { + await WidgetsBinding.instance.endOfFrame; + nameController.dispose(); + pathController.dispose(); + } +} + +Future showDeleteDownloadCategoryDialog(BuildContext context, String name) async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => shad.AlertDialog( + title: Text(dialogContext.l10n.deleteCategoryTitle), + content: Text(dialogContext.l10n.confirmDeleteNamed(name)), + actions: [ + shad.SecondaryButton( + onPressed: () => shad.closeOverlay(dialogContext, false), + child: Text(dialogContext.l10n.cancel), + ), + shad.DestructiveButton( + onPressed: () => shad.closeOverlay(dialogContext, true), + child: Text(dialogContext.l10n.delete), + ), + ], + ), + ); + return await overlay.future ?? false; +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_content_frame.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_content_frame.dart new file mode 100644 index 00000000..b8a93efc --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_content_frame.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../shared/theme/app_design_tokens.dart'; + +class SettingsContentFrame extends StatelessWidget { + const SettingsContentFrame({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppDesignTokens.space24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppDesignTokens.settingsContentMaxWidth), + child: SizedBox(width: double.infinity, child: child), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart new file mode 100644 index 00000000..e1d2de81 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart @@ -0,0 +1,80 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../core/utils/breakpoints.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; + +class SettingsItem extends StatelessWidget { + const SettingsItem({super.key, required this.title, required this.child, this.subtitle}); + + final String title; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + if (desktop) { + return LayoutBuilder( + builder: (context, constraints) { + const leadingSpace = 2.0; + const columnGap = AppDesignTokens.space24; + final maxControlWidth = + (constraints.maxWidth - leadingSpace - columnGap - AppDesignTokens.settingsItemMinLabelWidth).clamp( + 0.0, + double.infinity, + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: leadingSpace), + Expanded( + child: _SettingsItemLabel(title: title, subtitle: subtitle), + ), + const SizedBox(width: columnGap), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxControlWidth), + child: child, + ), + ], + ); + }, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingsItemLabel(title: title, subtitle: subtitle), + const SizedBox(height: 10), + Align(alignment: Alignment.centerLeft, child: child), + ], + ); + } +} + +class _SettingsItemLabel extends StatelessWidget { + const _SettingsItemLabel({required this.title, this.subtitle}); + + final String title; + final String? subtitle; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final subtitleText = subtitle?.trim(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + if (subtitleText != null && subtitleText.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)), + ], + ], + ); + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_language_select.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_language_select.dart new file mode 100644 index 00000000..c775571b --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_language_select.dart @@ -0,0 +1,84 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../core/utils/breakpoints.dart'; +import '../../../../l10n/l10n.dart'; + +class SettingsLanguageSelect extends StatelessWidget { + const SettingsLanguageSelect({super.key, required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + static List get supportedLocales => AppLocalizations.supportedLocales; + + static Set get supportedValues => supportedLocales.map(localeConfigValue).toSet(); + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile; + final popupMaxHeight = isMobile ? math.min(MediaQuery.sizeOf(context).height * 0.62, 420.0) : 240.0; + + return LayoutBuilder( + builder: (context, constraints) { + final width = isMobile || constraints.maxWidth < 220 ? constraints.maxWidth : 220.0; + return shad.Select( + key: const ValueKey('settings-language-select'), + value: value, + constraints: BoxConstraints.tightFor(width: width, height: isMobile ? 44 : 36), + popupConstraints: BoxConstraints(maxHeight: popupMaxHeight), + itemBuilder: (context, selectedValue) => Text(_labelFor(context, selectedValue)), + popup: (context) => shad.SelectPopup( + items: shad.SelectItemList( + children: [ + shad.SelectItemButton( + value: 'system', + child: _LanguageOptionLabel(label: context.l10n.followSystem, mobile: isMobile), + ), + for (final locale in supportedLocales) + shad.SelectItemButton( + value: localeConfigValue(locale), + child: _LanguageOptionLabel(label: lookupAppLocalizations(locale).label, mobile: isMobile), + ), + ], + ), + ), + onChanged: (selectedValue) { + if (selectedValue != null) { + onChanged(selectedValue); + } + }, + ); + }, + ); + } + + String _labelFor(BuildContext context, String selectedValue) { + if (selectedValue == 'system') return context.l10n.followSystem; + final locale = localeFromConfig(selectedValue); + if (locale != null && supportedValues.contains(localeConfigValue(locale))) { + return lookupAppLocalizations(locale).label; + } + return context.l10n.followSystem; + } +} + +class _LanguageOptionLabel extends StatelessWidget { + const _LanguageOptionLabel({required this.label, required this.mobile}); + + final String label; + final bool mobile; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: mobile ? 28 : 20, + child: Align( + alignment: Alignment.centerLeft, + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ); + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart new file mode 100644 index 00000000..53209d9f --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart' show Divider, Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../api/model/downloader_config.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_path_picker_field.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../l10n/l10n.dart'; + +class SettingsListEntry { + const SettingsListEntry({required this.id, required this.title, this.subtitle}); + + final String id; + final String title; + final String? subtitle; +} + +class SettingsListEditor extends StatelessWidget { + const SettingsListEditor({ + super.key, + required this.entries, + required this.onAdd, + required this.onEdit, + required this.onDelete, + this.addLabel, + this.addButtonKey, + }); + + final List entries; + final VoidCallback onAdd; + final ValueChanged onEdit; + final ValueChanged onDelete; + final String? addLabel; + final Key? addButtonKey; + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + final palette = AppPalette.of(context); + return SizedBox( + width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < entries.length; index++) ...[ + if (index > 0) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(color: palette.border), + ), + Row( + key: ValueKey('settings-list-entry-${entries[index].id}'), + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entries[index].title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 13), + ), + if (entries[index].subtitle case final subtitle?) ...[ + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: 11), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + shad.GhostButton( + key: ValueKey('edit-settings-list-${entries[index].id}'), + density: shad.ButtonDensity.icon, + onPressed: () => onEdit(index), + child: const Icon(Icons.edit_outlined, size: 17), + ), + shad.GhostButton( + key: ValueKey('delete-settings-list-${entries[index].id}'), + density: shad.ButtonDensity.icon, + onPressed: () => onDelete(index), + child: const Icon(Icons.delete_outline, size: 17), + ), + ], + ), + ], + if (entries.isNotEmpty) const SizedBox(height: 10), + Align( + alignment: desktop ? Alignment.centerRight : Alignment.centerLeft, + child: shad.SecondaryButton( + key: addButtonKey, + onPressed: onAdd, + leading: const Icon(Icons.add, size: 17), + child: Text(addLabel ?? context.l10n.add), + ), + ), + ], + ), + ); + } +} + +Future showTextSettingDialog( + BuildContext context, { + required String title, + required String fieldLabel, + String initialValue = '', + bool requireHttpUrl = false, + Future Function()? pickPath, + Future Function(String value)? onTest, +}) async { + final controller = TextEditingController(text: initialValue); + String? validationMessage; + bool testing = false; + String? testMessage; + bool? testSucceeded; + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + + String? validate() { + final value = controller.text.trim(); + if (value.isEmpty) return dialogContext.l10n.fieldRequired(fieldLabel); + if (requireHttpUrl && !(value.startsWith('http://') || value.startsWith('https://'))) { + return dialogContext.l10n.urlInvalid; + } + return null; + } + + Future test() async { + final error = validate(); + if (error != null) { + setDialogState(() => validationMessage = error); + return; + } + setDialogState(() { + testing = true; + validationMessage = null; + testMessage = null; + testSucceeded = null; + }); + try { + await onTest!(controller.text.trim()); + if (dialogContext.mounted) { + setDialogState(() { + testMessage = dialogContext.l10n.webhookTestSuccess; + testSucceeded = true; + }); + } + } catch (_) { + if (dialogContext.mounted) { + setDialogState(() { + testMessage = dialogContext.l10n.webhookTestFail; + testSucceeded = false; + }); + } + } finally { + if (dialogContext.mounted) setDialogState(() => testing = false); + } + } + + return shad.AlertDialog( + title: Text(title), + content: SizedBox( + width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 420.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + if (pickPath == null) + shad.TextField(controller: controller) + else + AppPathPickerField( + controller: controller, + desktopWidth: AppDesignTokens.settingsFormControlWidth, + onPick: () async { + final path = await pickPath(); + if (path != null && path.isNotEmpty) controller.text = path; + }, + ), + if (validationMessage != null || testMessage != null) ...[ + const SizedBox(height: 8), + Text( + validationMessage ?? testMessage!, + style: TextStyle( + color: validationMessage != null + ? palette.error + : testSucceeded == true + ? palette.success + : palette.error, + fontSize: 12, + ), + ), + ], + ], + ), + ), + actions: [ + if (onTest != null) + AppLoadingButton( + onPressed: test, + loading: testing, + icon: const Icon(Icons.send_outlined, size: 16), + child: Text(dialogContext.l10n.webhookTest), + ), + shad.SecondaryButton( + onPressed: () => shad.closeOverlay(dialogContext), + child: Text(dialogContext.l10n.cancel), + ), + AppPrimaryButton( + onPressed: () { + final error = validate(); + if (error != null) { + setDialogState(() => validationMessage = error); + return; + } + shad.closeOverlay(dialogContext, controller.text.trim()); + }, + child: Text(dialogContext.l10n.confirm), + ), + ], + ); + }, + ), + ); + try { + return await overlay.future; + } finally { + await WidgetsBinding.instance.endOfFrame; + controller.dispose(); + } +} + +class GithubMirrorDraft { + const GithubMirrorDraft({required this.type, required this.url}); + + final GithubMirrorType type; + final String url; +} + +Future showGithubMirrorDialog(BuildContext context, {GithubMirror? mirror}) async { + var selectedType = mirror?.type ?? GithubMirrorType.jsdelivr; + final controller = TextEditingController(text: mirror?.url ?? ''); + String? validationMessage; + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + return shad.AlertDialog( + title: Text(mirror == null ? dialogContext.l10n.addGithubMirror : dialogContext.l10n.editGithubMirror), + content: SizedBox( + width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 420.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(dialogContext.l10n.githubMirrorType, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + for (final type in GithubMirrorType.values) + shad.SecondaryButton( + onPressed: () => setDialogState(() => selectedType = type), + leading: selectedType == type ? const Icon(Icons.check, size: 15) : null, + child: Text(type.name), + ), + ], + ), + const SizedBox(height: 14), + Text(dialogContext.l10n.githubMirrorUrl, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + shad.TextField(controller: controller), + if (validationMessage != null) ...[ + const SizedBox(height: 8), + Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)), + ], + ], + ), + ), + actions: [ + shad.SecondaryButton( + onPressed: () => shad.closeOverlay(dialogContext), + child: Text(dialogContext.l10n.cancel), + ), + AppPrimaryButton( + onPressed: () { + final value = controller.text.trim(); + if (value.isEmpty || !(value.startsWith('http://') || value.startsWith('https://'))) { + setDialogState(() => validationMessage = dialogContext.l10n.urlInvalid); + return; + } + shad.closeOverlay(dialogContext, GithubMirrorDraft(type: selectedType, url: value)); + }, + child: Text(dialogContext.l10n.confirm), + ), + ], + ); + }, + ), + ); + try { + return await overlay.future; + } finally { + controller.dispose(); + } +} + +Future showDeleteSettingsEntryDialog(BuildContext context, String name) async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => shad.AlertDialog( + title: Text(dialogContext.l10n.confirmDeleteTitle), + content: Text(dialogContext.l10n.confirmDeleteNamed(name)), + actions: [ + shad.SecondaryButton( + onPressed: () => shad.closeOverlay(dialogContext, false), + child: Text(dialogContext.l10n.cancel), + ), + shad.DestructiveButton( + onPressed: () => shad.closeOverlay(dialogContext, true), + child: Text(dialogContext.l10n.delete), + ), + ], + ), + ); + return await overlay.future ?? false; +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/theme_settings_control.dart b/ui/flutter/lib/features/settings/presentation/widgets/theme_settings_control.dart new file mode 100644 index 00000000..933fd631 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/theme_settings_control.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; + +import '../../../../app/application/app_appearance_controller.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/theme/app_theme_color.dart'; +import '../../../../l10n/l10n.dart'; + +class ThemeModeSelector extends StatelessWidget { + const ThemeModeSelector({super.key, required this.value, required this.accent, required this.onChanged}); + + final AppThemeMode value; + final AppThemeColor accent; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final mobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile; + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxWidth.isFinite ? constraints.maxWidth : 390.0; + final optionWidth = ((available - 16) / 3).clamp(84.0, 116.0); + return Wrap( + spacing: 8, + runSpacing: 10, + alignment: mobile ? WrapAlignment.start : WrapAlignment.end, + children: [ + _ThemeModeOption( + mode: AppThemeMode.system, + label: context.l10n.themeSystem, + icon: Icons.desktop_windows_outlined, + width: optionWidth, + selected: value == AppThemeMode.system, + accent: accent, + onPressed: onChanged, + ), + _ThemeModeOption( + mode: AppThemeMode.light, + label: context.l10n.themeLight, + icon: Icons.light_mode_outlined, + width: optionWidth, + selected: value == AppThemeMode.light, + accent: accent, + onPressed: onChanged, + ), + _ThemeModeOption( + mode: AppThemeMode.dark, + label: context.l10n.themeDark, + icon: Icons.dark_mode_outlined, + width: optionWidth, + selected: value == AppThemeMode.dark, + accent: accent, + onPressed: onChanged, + ), + ], + ); + }, + ); + } +} + +class ThemeColorSelector extends StatelessWidget { + const ThemeColorSelector({super.key, required this.value, required this.onChanged}); + + final AppThemeColor value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final dark = AppPalette.of(context).bg.computeLuminance() < 0.2; + final mobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile; + final swatchSize = mobile ? 38.0 : 30.0; + return Wrap( + spacing: mobile ? 14 : 10, + runSpacing: 12, + alignment: mobile ? WrapAlignment.start : WrapAlignment.end, + children: [ + for (final color in AppThemeColor.values) + Semantics( + button: true, + selected: value == color, + label: _colorLabel(context, color), + child: GestureDetector( + key: ValueKey('theme-color-${color.key}'), + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(color), + child: SizedBox.square( + dimension: swatchSize, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: value == color + ? AppPalette.of(context).textPrimary + : AppPalette.of(context).border.withValues(alpha: 0), + width: 2, + ), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: DecoratedBox( + decoration: BoxDecoration( + color: dark ? color.dark : color.light, + shape: BoxShape.circle, + border: Border.all(color: AppPalette.of(context).border), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +String _colorLabel(BuildContext context, AppThemeColor color) => switch (color) { + AppThemeColor.green => context.l10n.colorGreen, + AppThemeColor.red => context.l10n.colorRed, + AppThemeColor.rose => context.l10n.colorRose, + AppThemeColor.orange => context.l10n.colorOrange, + AppThemeColor.cyan => context.l10n.colorCyan, + AppThemeColor.blue => context.l10n.colorBlue, + AppThemeColor.amber => context.l10n.colorAmber, + AppThemeColor.purple => context.l10n.colorPurple, +}; + +class _ThemeModeOption extends StatefulWidget { + const _ThemeModeOption({ + required this.mode, + required this.label, + required this.icon, + required this.width, + required this.selected, + required this.accent, + required this.onPressed, + }); + + final AppThemeMode mode; + final String label; + final IconData icon; + final double width; + final bool selected; + final AppThemeColor accent; + final ValueChanged onPressed; + + @override + State<_ThemeModeOption> createState() => _ThemeModeOptionState(); +} + +class _ThemeModeOptionState extends State<_ThemeModeOption> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Semantics( + button: true, + selected: widget.selected, + label: widget.label, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: ValueKey('theme-mode-${widget.mode.key}'), + behavior: HitTestBehavior.opaque, + onTap: () => widget.onPressed(widget.mode), + child: SizedBox( + width: widget.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 52, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _hovered ? palette.cardHoverBg : palette.cardBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius + 2), + border: Border.all( + color: widget.selected ? palette.brand : palette.border, + width: widget.selected ? 2 : 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + child: _ThemePreview(mode: widget.mode, accent: widget.accent), + ), + ), + const SizedBox(height: 7), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.icon, size: 14, color: palette.textSecondary), + const SizedBox(width: 5), + Flexible( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: widget.selected ? palette.textPrimary : palette.textSecondary, + fontSize: 12, + fontWeight: widget.selected ? FontWeight.w700 : FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ThemePreview extends StatelessWidget { + const _ThemePreview({required this.mode, required this.accent}); + + final AppThemeMode mode; + final AppThemeColor accent; + + @override + Widget build(BuildContext context) { + return switch (mode) { + AppThemeMode.light => _PreviewSurface( + background: AppThemePreviewColors.lightBackground, + side: AppThemePreviewColors.lightSide, + line: AppThemePreviewColors.lightLine, + accent: accent.light, + ), + AppThemeMode.dark => _PreviewSurface( + background: AppThemePreviewColors.darkBackground, + side: AppThemePreviewColors.darkSide, + line: AppThemePreviewColors.darkLine, + accent: accent.dark, + ), + AppThemeMode.system => Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppThemePreviewColors.lightBackground), + Align( + alignment: Alignment.centerRight, + child: FractionallySizedBox( + widthFactor: 0.5, + heightFactor: 1, + child: const ColoredBox(color: AppThemePreviewColors.darkBackground), + ), + ), + _PreviewSurface( + background: AppThemePreviewColors.transparent, + side: AppThemePreviewColors.systemSide, + line: AppThemePreviewColors.systemLine, + accent: accent.light, + ), + ], + ), + }; + } +} + +class _PreviewSurface extends StatelessWidget { + const _PreviewSurface({required this.background, required this.side, required this.line, required this.accent}); + + final Color background; + final Color side; + final Color line; + final Color accent; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: background, + child: Row( + children: [ + SizedBox(width: 14, child: ColoredBox(color: side)), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 6, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 3, + width: 32, + decoration: BoxDecoration(color: line, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(height: 5), + Container( + height: 3, + width: 22, + decoration: BoxDecoration( + color: line.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(2), + ), + ), + const Spacer(), + Container( + height: 3, + decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/application/pending_create_task.dart b/ui/flutter/lib/features/tasks/application/pending_create_task.dart new file mode 100644 index 00000000..179d0fb9 --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/pending_create_task.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/create_task.dart'; + +final pendingCreateTaskProvider = NotifierProvider(PendingCreateTask.new); + +class PendingCreateTask extends Notifier { + @override + CreateTask? build() => null; + + void set(CreateTask? task) { + state = task; + } + + void clear() { + state = null; + } +} diff --git a/ui/flutter/lib/features/tasks/application/pending_update_task.dart b/ui/flutter/lib/features/tasks/application/pending_update_task.dart new file mode 100644 index 00000000..591f3bf8 --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/pending_update_task.dart @@ -0,0 +1,51 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/create_task.dart'; + +class PendingUpdateTask { + const PendingUpdateTask({required this.id, required this.name}); + + final String id; + final String name; +} + +final pendingUpdateTaskProvider = NotifierProvider( + PendingUpdateTaskController.new, +); + +class PendingUpdateTaskController extends Notifier { + @override + PendingUpdateTask? build() => null; + + void set(PendingUpdateTask? task) { + state = task; + } + + void clear() { + state = null; + } +} + +class PendingUpdateRequest { + const PendingUpdateRequest({required this.task, required this.createTask}); + + final PendingUpdateTask task; + final CreateTask createTask; +} + +final pendingUpdateRequestProvider = NotifierProvider( + PendingUpdateRequestController.new, +); + +class PendingUpdateRequestController extends Notifier { + @override + PendingUpdateRequest? build() => null; + + void set(PendingUpdateRequest request) { + state = request; + } + + void clear() { + state = null; + } +} diff --git a/ui/flutter/lib/features/tasks/application/task_batch_selection_controller.dart b/ui/flutter/lib/features/tasks/application/task_batch_selection_controller.dart new file mode 100644 index 00000000..8fc626ce --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/task_batch_selection_controller.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; + +class TaskBatchSelectionController extends ChangeNotifier { + final Set _selectedIds = {}; + + int get count => _selectedIds.length; + bool get isEmpty => _selectedIds.isEmpty; + bool get isNotEmpty => _selectedIds.isNotEmpty; + + bool contains(String taskId) => _selectedIds.contains(taskId); + + bool areAllSelected(Iterable taskIds) { + var hasTasks = false; + for (final taskId in taskIds) { + hasTasks = true; + if (!_selectedIds.contains(taskId)) return false; + } + return hasTasks; + } + + List toList() => _selectedIds.toList(growable: false); + + Iterable where(bool Function(String taskId) test) => _selectedIds.where(test); + + void toggle(String taskId) { + if (!_selectedIds.remove(taskId)) { + _selectedIds.add(taskId); + } + notifyListeners(); + } + + void replaceWith(Iterable taskIds) { + final next = taskIds.toSet(); + if (setEquals(_selectedIds, next)) return; + _selectedIds + ..clear() + ..addAll(next); + notifyListeners(); + } + + void removeAll(Iterable taskIds) { + var changed = false; + for (final taskId in taskIds) { + changed = _selectedIds.remove(taskId) || changed; + } + if (changed) notifyListeners(); + } + + void clear() { + if (_selectedIds.isEmpty) return; + _selectedIds.clear(); + notifyListeners(); + } +} diff --git a/ui/flutter/lib/features/tasks/application/task_runtime_status_provider.dart b/ui/flutter/lib/features/tasks/application/task_runtime_status_provider.dart new file mode 100644 index 00000000..722eb510 --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/task_runtime_status_provider.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/task.dart'; +import '../../../core/capabilities/app_capabilities.dart'; + +final taskRuntimeStatusProvider = FutureProvider.autoDispose.family((ref, taskId) async { + final status = await ref.read(gopeedServiceProvider).getTaskStatus(taskId); + final refreshTimer = Timer(const Duration(seconds: 1), ref.invalidateSelf); + ref.onDispose(refreshTimer.cancel); + return status; +}); diff --git a/ui/flutter/lib/features/tasks/application/task_stats_provider.dart b/ui/flutter/lib/features/tasks/application/task_stats_provider.dart new file mode 100644 index 00000000..9aaa7eb1 --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/task_stats_provider.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/task.dart'; +import '../../../api/model/task_stats.dart'; +import '../../../core/capabilities/app_capabilities.dart'; + +typedef TaskStatsRequest = ({String taskId, Protocol? protocol}); + +final taskStatsProvider = FutureProvider.autoDispose.family((ref, request) async { + final refreshTimer = Timer(const Duration(seconds: 2), ref.invalidateSelf); + ref.onDispose(refreshTimer.cancel); + final json = await ref.read(gopeedServiceProvider).getTaskStats(request.taskId); + return TaskStats.fromJson(request.protocol, json); +}); diff --git a/ui/flutter/lib/features/tasks/application/tasks_controller.dart b/ui/flutter/lib/features/tasks/application/tasks_controller.dart new file mode 100644 index 00000000..947a0de4 --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/tasks_controller.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/model/request.dart'; +import '../../../api/model/resolve_task.dart'; +import '../../../api/model/task.dart' as api_model; +import '../../../core/capabilities/app_capabilities.dart'; +import '../domain/task_record.dart'; + +final tasksControllerProvider = AsyncNotifierProvider(TasksController.new); + +class TasksState { + const TasksState({ + required this.tasks, + this.downloadSpeedBytesPerSecond = 0, + this.uploadSpeedBytesPerSecond = 0, + this.lastError, + }); + + final List tasks; + final int downloadSpeedBytesPerSecond; + final int uploadSpeedBytesPerSecond; + final Object? lastError; + + TasksState copyWith({ + List? tasks, + int? downloadSpeedBytesPerSecond, + int? uploadSpeedBytesPerSecond, + Object? lastError, + }) { + return TasksState( + tasks: tasks ?? this.tasks, + downloadSpeedBytesPerSecond: downloadSpeedBytesPerSecond ?? this.downloadSpeedBytesPerSecond, + uploadSpeedBytesPerSecond: uploadSpeedBytesPerSecond ?? this.uploadSpeedBytesPerSecond, + lastError: lastError, + ); + } +} + +({int downloadBytesPerSecond, int uploadBytesPerSecond}) aggregateTaskTransferSpeeds(Iterable tasks) { + var downloadBytesPerSecond = 0; + var uploadBytesPerSecond = 0; + for (final task in tasks) { + if (task.status == api_model.Status.running) { + downloadBytesPerSecond += task.progress.speed > 0 ? task.progress.speed : 0; + } + if (task.uploading && (task.status == api_model.Status.running || task.status == api_model.Status.done)) { + uploadBytesPerSecond += task.progress.uploadSpeed > 0 ? task.progress.uploadSpeed : 0; + } + } + return (downloadBytesPerSecond: downloadBytesPerSecond, uploadBytesPerSecond: uploadBytesPerSecond); +} + +class TasksController extends AsyncNotifier { + Timer? _timer; + + @override + Future build() async { + _timer = Timer.periodic(const Duration(seconds: 1), (_) => unawaited(refresh(silent: true))); + ref.onDispose(() => _timer?.cancel()); + return _fetch(); + } + + Future refresh({bool silent = false}) async { + if (!silent) { + state = const AsyncValue.loading(); + } + try { + state = AsyncValue.data(await _fetch()); + } catch (error, stackTrace) { + final previous = state.value; + if (previous != null && silent) { + state = AsyncValue.data(previous.copyWith(lastError: error)); + } else { + state = AsyncValue.error(error, stackTrace); + } + } + } + + Future pause(String id) async { + await ref.read(gopeedServiceProvider).pauseTask(id); + await refresh(silent: true); + } + + Future resume(String id) async { + await ref.read(gopeedServiceProvider).continueTask(id); + await refresh(silent: true); + } + + Future updateUrl(String id, String url, {Map headers = const {}}) async { + await updateRequest( + id, + Request( + url: url, + extra: ReqExtraHttp(header: headers).toJson(), + ), + ); + } + + Future updateRequest(String id, Request request) async { + await ref.read(gopeedServiceProvider).patchTask(id, ResolveTask(req: request)); + await ref.read(gopeedServiceProvider).continueTask(id); + await refresh(silent: true); + } + + Future pauseAll(List? ids) async { + await ref.read(gopeedServiceProvider).pauseAllTasks(ids); + await refresh(silent: true); + } + + Future resumeAll(List? ids) async { + await ref.read(gopeedServiceProvider).continueAllTasks(ids); + await refresh(silent: true); + } + + Future deleteSelected(List? ids, {bool force = false}) async { + await ref.read(gopeedServiceProvider).deleteTasks(ids, force); + await refresh(silent: true); + } + + Future _fetch() async { + final tasks = await ref.read(gopeedServiceProvider).getTasks(api_model.Status.values); + final transferSpeeds = aggregateTaskTransferSpeeds(tasks); + tasks.sort((a, b) { + if (a.status == api_model.Status.running && b.status != api_model.Status.running) return -1; + if (a.status != api_model.Status.running && b.status == api_model.Status.running) return 1; + return b.updatedAt.compareTo(a.updatedAt); + }); + return TasksState( + tasks: tasks.map(TaskRecord.fromApi).toList(growable: false), + downloadSpeedBytesPerSecond: transferSpeeds.downloadBytesPerSecond, + uploadSpeedBytesPerSecond: transferSpeeds.uploadBytesPerSecond, + ); + } +} diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart new file mode 100644 index 00000000..44a5a426 --- /dev/null +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -0,0 +1,327 @@ +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as path; + +import '../../../api/model/resource.dart' as api_resource; +import '../../../api/model/task.dart' as api_task; +import '../../../core/icons/gopeed_icons.dart'; +import '../../../core/utils/byte_size_formatter.dart'; +import '../../../core/utils/transfer_rate_formatter.dart'; + +enum TaskStatus { downloading, completed, paused, failed } + +enum TaskAssetType { + file, + folder, + torrentFolder, + torrent, + ed2k, + installer, + androidPackage, + iosPackage, + diskImage, + web, + text, + pdf, + document, + spreadsheet, + presentation, + archive, + image, + audio, + video, + code, + ebook, + font, + database, +} + +class TaskFileNode { + const TaskFileNode({required this.path, required this.name, required this.sizeBytes, this.downloadedBytes}); + + final String path; + final String name; + final int sizeBytes; + final int? downloadedBytes; + + double? get progress { + final downloaded = downloadedBytes; + if (downloaded == null || sizeBytes <= 0) return null; + return (downloaded / sizeBytes).clamp(0.0, 1.0); + } +} + +class TaskRecord { + const TaskRecord({ + required this.id, + required this.name, + required this.status, + required this.downloaded, + required this.url, + required this.storagePath, + required this.files, + required this.uploading, + this.isFolder = false, + this.protocol, + this.requestHeaders = const {}, + this.total, + this.speed, + this.uploadSpeed, + this.remaining, + this.downloadDuration, + this.createdAt, + this.progress, + this.error, + this.completedLabel, + this.downloadedBytes, + this.totalBytes, + this.speedBytes, + this.uploadedBytes, + this.remainingSeconds, + this.waiting = false, + }) : assert(!uploading || uploadSpeed != null); + + final String id; + final String name; + final TaskStatus status; + final String downloaded; + final String? total; + final String? speed; + final String? uploadSpeed; + final String? remaining; + final Duration? downloadDuration; + final DateTime? createdAt; + final double? progress; + final String url; + final String storagePath; + final String? error; + final String? completedLabel; + final int? downloadedBytes; + final int? totalBytes; + final int? speedBytes; + final int? uploadedBytes; + final int? remainingSeconds; + final bool waiting; + final List files; + final bool uploading; + final bool isFolder; + final api_task.Protocol? protocol; + final Map requestHeaders; + + bool get canUpdateUrl => + protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed); + + bool get isIndeterminate => status == TaskStatus.downloading && total == null; + + TaskAssetType get assetType => _assetType(name, isFolder: isFolder, protocol: protocol); + + IconData get icon => taskFileTypeIcon(name, isFolder: isFolder, protocol: protocol); + + factory TaskRecord.fromApi(api_task.Task task) { + final totalBytes = task.meta.res?.size ?? 0; + final downloadedBytes = task.progress.downloaded; + final progress = totalBytes > 0 ? (downloadedBytes / totalBytes).clamp(0.0, 1.0) : null; + final status = _statusFromApi(task.status); + final name = task.name.isNotEmpty ? task.name : task.meta.res?.name; + final fileName = (name == null || name.isEmpty) ? task.meta.req.url : name; + final rawUrl = task.meta.req.rawUrl; + final displayUrl = rawUrl != null && rawUrl.isNotEmpty ? rawUrl : task.meta.req.url; + final storagePath = task.meta.opts.path.isEmpty ? fileName : path.join(task.meta.opts.path, fileName); + + return TaskRecord( + id: task.id, + name: fileName, + status: status, + downloaded: _formatBytes(downloadedBytes), + total: totalBytes > 0 ? _formatBytes(totalBytes) : null, + speed: task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null, + uploadSpeed: task.uploading ? TransferRateFormatter.format(task.progress.uploadSpeed).text : null, + remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes), + downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null, + createdAt: task.createdAt, + waiting: task.status == api_task.Status.wait, + progress: progress, + url: displayUrl, + storagePath: storagePath, + downloadedBytes: downloadedBytes, + totalBytes: totalBytes > 0 ? totalBytes : null, + speedBytes: task.progress.speed, + uploadedBytes: task.progress.uploaded, + files: _fileNodes(task.meta.res?.files), + uploading: task.uploading, + isFolder: task.meta.res?.name.isNotEmpty ?? false, + protocol: task.protocol, + requestHeaders: _requestHeaders(task.meta.req.extra), + ); + } +} + +Map _requestHeaders(Object? extra) { + if (extra is! Map) return const {}; + final header = extra['header']; + if (header is! Map) return const {}; + return {for (final entry in header.entries) entry.key.toString(): entry.value.toString()}; +} + +TaskStatus _statusFromApi(api_task.Status status) { + return switch (status) { + api_task.Status.running || api_task.Status.wait => TaskStatus.downloading, + api_task.Status.done => TaskStatus.completed, + api_task.Status.pause => TaskStatus.paused, + api_task.Status.error => TaskStatus.failed, + api_task.Status.ready => TaskStatus.paused, + }; +} + +const _installerExtensions = {'exe', 'msi', 'dmg', 'deb', 'rpm', 'pkg', 'appimage', 'flatpak', 'snap'}; +const _androidPackageExtensions = {'apk', 'aab'}; +const _iosPackageExtensions = {'ipa'}; +const _diskImageExtensions = {'iso', 'img'}; +const _webExtensions = {'html', 'htm', 'xhtml'}; +const _textExtensions = {'txt', 'md', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini', 'conf', 'cfg'}; +const _pdfExtensions = {'pdf'}; +const _documentExtensions = {'doc', 'docx', 'odt', 'rtf', 'pages'}; +const _spreadsheetExtensions = {'xls', 'xlsx', 'ods', 'numbers'}; +const _presentationExtensions = {'ppt', 'pptx', 'odp', 'key'}; +const _archiveExtensions = {'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz', 'tbz2', 'txz', 'cab'}; +const _imageExtensions = { + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'tif', + 'tiff', + 'svg', + 'webp', + 'heic', + 'avif', + 'ico', + 'raw', +}; +const _audioExtensions = {'mp3', 'wav', 'flac', 'aac', 'ogg', 'oga', 'wma', 'm4a', 'opus', 'aiff'}; +const _videoExtensions = {'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', '3gp', 'ts'}; +const _codeExtensions = { + 'js', + 'jsx', + 'ts', + 'tsx', + 'css', + 'scss', + 'less', + 'xml', + 'java', + 'kt', + 'kts', + 'cpp', + 'cc', + 'c', + 'h', + 'hpp', + 'dart', + 'py', + 'rb', + 'php', + 'swift', + 'go', + 'rs', + 'sh', + 'bash', + 'zsh', + 'ps1', + 'sql', + 'vue', + 'svelte', + 'git', +}; +const _ebookExtensions = {'epub', 'mobi', 'azw', 'azw3', 'fb2'}; +const _fontExtensions = {'ttf', 'otf', 'woff', 'woff2'}; +const _databaseExtensions = {'db', 'sqlite', 'sqlite3', 'mdb', 'accdb', 'parquet'}; + +IconData taskFileTypeIcon(String name, {bool isFolder = false, api_task.Protocol? protocol}) { + return switch (_assetType(name, isFolder: isFolder, protocol: protocol)) { + TaskAssetType.file => GopeedIcons.file, + TaskAssetType.folder => GopeedIcons.folder, + TaskAssetType.torrentFolder => GopeedIcons.folderBt, + TaskAssetType.torrent => GopeedIcons.protocolBt, + TaskAssetType.ed2k => GopeedIcons.protocolEd2k, + TaskAssetType.installer => GopeedIcons.fileInstaller, + TaskAssetType.androidPackage => GopeedIcons.fileAndroid, + TaskAssetType.iosPackage => GopeedIcons.fileIos, + TaskAssetType.diskImage => GopeedIcons.fileDiskImage, + TaskAssetType.web => GopeedIcons.fileWeb, + TaskAssetType.text => GopeedIcons.fileText, + TaskAssetType.pdf => GopeedIcons.filePdf, + TaskAssetType.document => GopeedIcons.fileDocument, + TaskAssetType.spreadsheet => GopeedIcons.fileSpreadsheet, + TaskAssetType.presentation => GopeedIcons.filePresentation, + TaskAssetType.archive => GopeedIcons.fileArchive, + TaskAssetType.image => GopeedIcons.fileImage, + TaskAssetType.audio => GopeedIcons.fileAudio, + TaskAssetType.video => GopeedIcons.fileVideo, + TaskAssetType.code => GopeedIcons.fileCode, + TaskAssetType.ebook => GopeedIcons.fileEbook, + TaskAssetType.font => GopeedIcons.fileFont, + TaskAssetType.database => GopeedIcons.fileDatabase, + }; +} + +TaskAssetType _assetType(String name, {required bool isFolder, required api_task.Protocol? protocol}) { + if (isFolder) { + return protocol == api_task.Protocol.bt ? TaskAssetType.torrentFolder : TaskAssetType.folder; + } + + final extension = _fileExtension(name); + if (extension == 'torrent') return TaskAssetType.torrent; + if (_installerExtensions.contains(extension)) return TaskAssetType.installer; + if (_androidPackageExtensions.contains(extension)) return TaskAssetType.androidPackage; + if (_iosPackageExtensions.contains(extension)) return TaskAssetType.iosPackage; + if (_diskImageExtensions.contains(extension)) return TaskAssetType.diskImage; + if (_webExtensions.contains(extension)) return TaskAssetType.web; + if (_textExtensions.contains(extension)) return TaskAssetType.text; + if (_pdfExtensions.contains(extension)) return TaskAssetType.pdf; + if (_documentExtensions.contains(extension)) return TaskAssetType.document; + if (_spreadsheetExtensions.contains(extension)) return TaskAssetType.spreadsheet; + if (_presentationExtensions.contains(extension)) return TaskAssetType.presentation; + if (_archiveExtensions.contains(extension)) return TaskAssetType.archive; + if (_imageExtensions.contains(extension)) return TaskAssetType.image; + if (_audioExtensions.contains(extension)) return TaskAssetType.audio; + if (_videoExtensions.contains(extension)) return TaskAssetType.video; + if (_codeExtensions.contains(extension)) return TaskAssetType.code; + if (_ebookExtensions.contains(extension)) return TaskAssetType.ebook; + if (_fontExtensions.contains(extension)) return TaskAssetType.font; + if (_databaseExtensions.contains(extension)) return TaskAssetType.database; + + return switch (protocol) { + api_task.Protocol.bt => TaskAssetType.torrent, + api_task.Protocol.ed2k => TaskAssetType.ed2k, + api_task.Protocol.http || null => TaskAssetType.file, + }; +} + +String _fileExtension(String name) { + final pathWithoutQuery = name.split(RegExp(r'[?#]')).first; + final extension = path.extension(pathWithoutQuery).replaceFirst('.', '').toLowerCase(); + return extension.length <= 10 ? extension : ''; +} + +int? _remainingSeconds(api_task.Task task, int totalBytes, int downloadedBytes) { + if (task.status != api_task.Status.running) return null; + final speed = task.progress.speed; + if (speed <= 0 || totalBytes <= downloadedBytes) { + return null; + } + return ((totalBytes - downloadedBytes) / speed).ceil(); +} + +List _fileNodes(List? files) { + if (files == null || files.isEmpty) { + return const []; + } + return files + .map((file) => TaskFileNode(path: file.path, name: file.name, sizeBytes: file.size)) + .toList(growable: false); +} + +String _formatBytes(int bytes) { + return ByteSizeFormatter.format(bytes); +} diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart new file mode 100644 index 00000000..325f5b4a --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -0,0 +1,1771 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:desktop_multi_window/desktop_multi_window.dart' as multi_window; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' + show Clipboard, FilteringTextInputFormatter, TextInputAction, TextInputType, TextInputFormatter; +import 'package:flutter/widgets.dart' as flutter show Flexible, Row; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:path/path.dart' as path; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../../api/model/create_task.dart'; +import '../../../../api/model/create_task_batch.dart'; +import '../../../../api/model/downloader_config.dart'; +import '../../../../api/model/options.dart' as api_options; +import '../../../../api/model/request.dart'; +import '../../../../api/model/resolve_result.dart'; +import '../../../../api/model/resolve_task.dart'; +import '../../../../core/capabilities/app_capabilities.dart'; +import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/utils/text_wrap.dart'; +import '../../../../core/window/app_window_chrome.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_choice_segmented_control.dart'; +import '../../../../shared/widgets/app_http_headers_editor.dart'; +import '../../../../shared/widgets/app_path_picker_field.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../util/util.dart'; +import '../../application/pending_create_task.dart'; +import '../widgets/resolve_file_tree.dart'; + +class CreateTaskWindowPage extends ConsumerStatefulWidget { + const CreateTaskWindowPage({super.key, this.windowController, this.initialTask}); + + final multi_window.WindowController? windowController; + final CreateTask? initialTask; + + @override + ConsumerState createState() => _CreateTaskWindowPageState(); +} + +class _CreateTaskWindowPageState extends ConsumerState { + final _urlController = TextEditingController(); + final _renameController = TextEditingController(); + final _connectionsController = TextEditingController(text: '16'); + final _directoryController = TextEditingController(); + final _proxyServerController = TextEditingController(); + final _proxyPortController = TextEditingController(); + final _proxyUsernameController = TextEditingController(); + final _proxyPasswordController = TextEditingController(); + final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']); + final _trackersController = TextEditingController(); + final _archivePasswordController = TextEditingController(); + final _formScrollController = ScrollController(); + + String _httpMethod = 'GET'; + String _httpBody = ''; + String? _initialRawUrl; + Map? _initialLabels; + RequestProxyMode _proxyMode = RequestProxyMode.follow; + String _proxyScheme = 'http'; + bool _skipVerifyCert = false; + bool? _autoTorrent; + bool? _deleteTorrentAfterDownload; + bool? _autoExtract; + bool _deleteAfterExtract = false; + List _downloadCategories = const []; + + bool _showAdvanced = false; + int _advancedScrollRequest = 0; + bool _directDownload = false; + int _protocolTab = 0; + bool _creating = false; + bool _draggingTorrent = false; + bool _asDefaultPath = false; + String _configuredDownloadDirectory = ''; + String _fileDataUri = ''; + bool _programmaticUrlChange = false; + + @override + void initState() { + super.initState(); + _urlController.addListener(_handleUrlChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider); + _applyInitialTask(task); + if (widget.initialTask == null) { + ref.read(pendingCreateTaskProvider.notifier).clear(); + } + if (task == null || _urlController.text.trim().isEmpty) { + unawaited(_loadClipboardUrl()); + } + }); + unawaited(_loadDefaults()); + } + + @override + void dispose() { + _urlController.removeListener(_handleUrlChanged); + _urlController.dispose(); + _renameController.dispose(); + _connectionsController.dispose(); + _directoryController.dispose(); + _proxyServerController.dispose(); + _proxyPortController.dispose(); + _proxyUsernameController.dispose(); + _proxyPasswordController.dispose(); + _httpHeadersController.dispose(); + _trackersController.dispose(); + _archivePasswordController.dispose(); + _formScrollController.dispose(); + super.dispose(); + } + + void _toggleAdvanced() { + final expanding = !_showAdvanced; + final request = ++_advancedScrollRequest; + setState(() => _showAdvanced = expanding); + if (expanding) { + unawaited(_scrollForAdvancedOptions(request)); + } else if (_formScrollController.hasClients) { + final position = _formScrollController.position; + position.jumpTo(position.pixels); + } + } + + Future _scrollForAdvancedOptions(int request) async { + await Future.delayed(_advancedScrollDelay); + for (var attempt = 0; attempt < _advancedScrollExtentChecks; attempt++) { + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !_showAdvanced || request != _advancedScrollRequest || !_formScrollController.hasClients) { + return; + } + final position = _formScrollController.position; + final target = (position.pixels + _advancedScrollDistance).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (target - position.pixels >= 1) { + try { + await _formScrollController.animateTo(target, duration: _advancedScrollDuration, curve: Curves.easeOutCubic); + } catch (_) { + // The window or scroll position can be disposed while the animation is running. + } + return; + } + if (attempt + 1 < _advancedScrollExtentChecks) { + await Future.delayed(_advancedScrollExtentRetryDelay); + } + } + } + + void _handleUrlChanged() { + if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { + _fileDataUri = ''; + } + _recognizeMagnetUri(_urlController.text.trim()); + } + + void _applyInitialTask(CreateTask? task) { + if (task == null || !mounted) return; + final req = task.req; + final opts = task.opts; + setState(() { + if (req?.url.isNotEmpty == true) { + _urlController.text = req!.url; + _initialRawUrl = req.rawUrl; + _initialLabels = req.labels == null ? null : Map.of(req.labels!); + _applyInitialProxy(req.proxy); + _skipVerifyCert = req.skipVerifyCert; + if (req.proxy != null || req.skipVerifyCert) { + _showAdvanced = true; + } + switch (_parseProtocol(req.url)) { + case _TaskProtocol.http: + if (req.extra is Map) { + final extra = ReqExtraHttp.fromJson(Map.from(req.extra! as Map)); + _httpMethod = extra.method; + _httpBody = extra.body; + if (extra.header.isNotEmpty) { + _replaceHttpHeaders(extra.header); + } + _showAdvanced = true; + } + break; + case _TaskProtocol.bt: + _protocolTab = 1; + if (req.extra is Map) { + final extra = ReqExtraBt.fromJson(Map.from(req.extra! as Map)); + _trackersController.text = extra.trackers.join('\n'); + _showAdvanced = true; + } + break; + case _TaskProtocol.ed2k: + case null: + break; + } + } + if (opts != null) { + if (opts.name.isNotEmpty) { + _renameController.text = opts.name; + } + if (opts.path.isNotEmpty) { + _directoryController.text = opts.path; + } + if (opts.extra is Map) { + final extra = api_options.OptsExtraHttp.fromJson(Map.from(opts.extra! as Map)); + if (extra.connections > 0) { + _connectionsController.text = extra.connections.toString(); + } + _autoTorrent = extra.autoTorrent; + _deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload; + _autoExtract = extra.autoExtract; + _archivePasswordController.text = extra.archivePassword; + _deleteAfterExtract = extra.deleteAfterExtract; + if (extra.autoTorrent != null || + extra.deleteTorrentAfterDownload != null || + extra.autoExtract != null || + extra.archivePassword.isNotEmpty || + extra.deleteAfterExtract) { + _showAdvanced = true; + } + } + } + }); + } + + void _applyInitialProxy(RequestProxy? proxy) { + _proxyMode = proxy?.mode ?? RequestProxyMode.follow; + const supportedSchemes = {'http', 'https', 'socks5'}; + _proxyScheme = supportedSchemes.contains(proxy?.scheme) ? proxy!.scheme : 'http'; + _proxyUsernameController.text = proxy?.usr ?? ''; + _proxyPasswordController.text = proxy?.pwd ?? ''; + + final host = proxy?.host.trim() ?? ''; + if (host.isEmpty) { + _proxyServerController.clear(); + _proxyPortController.clear(); + return; + } + final parsed = Uri.tryParse('$_proxyScheme://$host'); + if (parsed != null && parsed.host.isNotEmpty) { + _proxyServerController.text = parsed.host; + _proxyPortController.text = parsed.hasPort ? parsed.port.toString() : ''; + return; + } + _proxyServerController.text = host; + _proxyPortController.clear(); + } + + void _replaceHttpHeaders(Map headers) { + _httpHeadersController.replace(headers); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + + final page = Scaffold( + backgroundColor: palette.sideBg, + child: Padding( + padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0), + child: Column( + children: [ + Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Text( + context.l10n.create, + style: TextStyle(color: palette.textPrimary, fontSize: 18, fontWeight: FontWeight.w700), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + key: const ValueKey('create-task-form-scroll'), + controller: _formScrollController, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 20), + child: Column( + children: [ + _FormRow( + label: context.l10n.downloadLink, + child: DropTarget( + enable: !_creating, + onDragEntered: (_) => _setDraggingTorrent(true), + onDragExited: (_) => _setDraggingTorrent(false), + onDragDone: (details) { + _setDraggingTorrent(false); + unawaited(_handleDroppedTorrent(details)); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: _draggingTorrent ? Border.all(color: palette.brand, width: 2) : null, + color: _draggingTorrent ? palette.filterActiveBg : null, + ), + child: Stack( + children: [ + SizedBox( + height: 120, + child: TextField( + controller: _urlController, + hintText: context.l10n.pasteDownloadLinks, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + maxLines: null, + minLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + filled: true, + border: Border.all( + color: _draggingTorrent ? palette.brand : palette.border, + width: _draggingTorrent ? 0 : 1, + ), + borderRadius: BorderRadius.circular(4), + ), + ), + Positioned( + right: 8, + bottom: 8, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _MiniInputAction(icon: Icons.folder_open_outlined, onPressed: _pickTorrentFile), + const SizedBox(width: 6), + _MiniInputAction(icon: Icons.history, onPressed: _showCreateHistory), + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + _FormRow( + label: context.l10n.rename, + child: _WindowTextField( + key: const ValueKey('create-task-rename-input'), + controller: _renameController, + hintText: context.l10n.keepOriginalName, + ), + ), + const SizedBox(height: 16), + _FormRow( + label: context.l10n.connections, + child: _WindowTextField(controller: _connectionsController, hintText: context.l10n.enterCount), + ), + const SizedBox(height: 16), + _FormRow( + label: context.l10n.directory, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppPathPickerField.downloadDirectory( + key: const ValueKey('create-task-directory-component'), + fieldKey: const ValueKey('create-task-directory-input'), + pickerKey: const ValueKey('create-task-directory-picker'), + controller: _directoryController, + hintText: context.l10n.chooseDownloadDirectory, + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + onChanged: (_) { + if (_asDefaultPath) { + setState(() => _asDefaultPath = false); + } + }, + onDirectoryPicked: (pickedDirectory) => setState( + () => _asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory), + ), + ), + const SizedBox(height: 8), + _DirectoryOptions( + key: const ValueKey('create-task-directory-options-row'), + asDefaultPath: _asDefaultPath, + onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value), + shortcuts: [ + for (final entry in _downloadCategories.indexed) + _DirectoryShortcut( + index: entry.$1, + label: _categoryDisplayName(context, entry.$2), + path: _renderPathPlaceholders(entry.$2.path), + ), + ], + onShortcutSelected: (shortcut) { + _directoryController.text = shortcut.path; + if (_asDefaultPath) { + setState(() => _asDefaultPath = false); + } + }, + ), + ], + ), + ), + const SizedBox(height: 14), + _FormRow( + label: context.l10n.directDownload, + child: _DirectDownloadToggle( + value: _directDownload, + onChanged: (value) => setState(() => _directDownload = value), + ), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: _toggleAdvanced, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.advanced, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + _TripleChevronIcon(expanded: _showAdvanced, color: palette.textSecondary), + ], + ), + ), + ), + AnimatedCrossFade( + duration: _advancedExpandDuration, + firstCurve: Curves.easeOutCubic, + secondCurve: Curves.easeInCubic, + sizeCurve: Curves.easeOutCubic, + crossFadeState: _showAdvanced ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: Padding( + key: const ValueKey('create-task-advanced-content'), + padding: const EdgeInsets.only(top: 18, bottom: 4), + child: Column( + children: [ + _FormRow( + label: context.l10n.proxy, + child: AppChoiceSegmentedControl( + key: const ValueKey('create-task-proxy-mode'), + value: _proxyMode, + buttonKeyPrefix: 'create-task-proxy-mode', + alignment: WrapAlignment.start, + options: [ + AppChoiceOption( + value: RequestProxyMode.follow, + key: 'follow', + label: context.l10n.followSettings, + icon: Icons.settings_suggest_outlined, + ), + AppChoiceOption( + value: RequestProxyMode.none, + key: 'none', + label: context.l10n.noProxy, + icon: Icons.block_outlined, + ), + AppChoiceOption( + value: RequestProxyMode.custom, + key: 'custom', + label: context.l10n.customProxy, + icon: Icons.tune_outlined, + ), + ], + onChanged: (value) => setState(() => _proxyMode = value), + ), + ), + if (_proxyMode == RequestProxyMode.custom) ...[ + const SizedBox(height: 12), + _FormRow( + label: context.l10n.proxyProtocol, + child: AppChoiceSegmentedControl( + key: const ValueKey('create-task-proxy-scheme'), + value: _proxyScheme, + buttonKeyPrefix: 'create-task-proxy-scheme', + alignment: WrapAlignment.start, + options: const [ + AppChoiceOption(value: 'http', label: 'HTTP', icon: Icons.http_outlined), + AppChoiceOption(value: 'https', label: 'HTTPS', icon: Icons.https_outlined), + AppChoiceOption(value: 'socks5', label: 'SOCKS5', icon: Icons.security_outlined), + ], + onChanged: (value) => setState(() => _proxyScheme = value), + ), + ), + const SizedBox(height: 12), + _FormRow( + label: context.l10n.server, + child: Row( + children: [ + Expanded( + flex: 618, + child: _WindowTextField( + key: const ValueKey('create-task-proxy-server'), + controller: _proxyServerController, + hintText: context.l10n.server, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 382, + child: _WindowTextField( + key: const ValueKey('create-task-proxy-port'), + controller: _proxyPortController, + hintText: context.l10n.port, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _FormRow( + label: context.l10n.username, + child: Row( + children: [ + Expanded( + child: _WindowTextField( + key: const ValueKey('create-task-proxy-username'), + controller: _proxyUsernameController, + hintText: context.l10n.username, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _WindowTextField( + key: const ValueKey('create-task-proxy-password'), + controller: _proxyPasswordController, + hintText: context.l10n.password, + obscureText: true, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 18), + Container(height: 1, color: palette.border), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: palette.surfaceSoft, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SegmentButton( + label: 'HTTP', + active: _protocolTab == 0, + onTap: () => setState(() => _protocolTab = 0), + ), + _SegmentButton( + label: 'BitTorrent', + active: _protocolTab == 1, + onTap: () => setState(() => _protocolTab = 1), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + if (_protocolTab == 0) ...[ + AppHttpHeadersEditor( + controller: _httpHeadersController, + label: context.l10n.httpHeader, + keyPrefix: 'create-task-http-header', + ), + const SizedBox(height: 14), + _FormRow( + label: context.l10n.skipVerifyCert, + child: _OptionSwitch( + key: const ValueKey('create-task-skip-verify-cert'), + value: _skipVerifyCert, + onChanged: (value) => setState(() => _skipVerifyCert = value), + ), + ), + const SizedBox(height: 14), + _FormRow( + label: context.l10n.autoTorrentEnable, + child: _OptionSwitch( + key: const ValueKey('create-task-auto-torrent'), + value: _autoTorrent ?? false, + onChanged: (value) => setState(() => _autoTorrent = value), + ), + ), + if (_autoTorrent == true) ...[ + const SizedBox(height: 12), + _FormRow( + label: context.l10n.autoTorrentDeleteAfterDownload, + child: _OptionSwitch( + key: const ValueKey('create-task-delete-torrent'), + value: _deleteTorrentAfterDownload ?? false, + onChanged: (value) => setState(() => _deleteTorrentAfterDownload = value), + ), + ), + ], + const SizedBox(height: 14), + _FormRow( + label: context.l10n.autoExtract, + child: _OptionSwitch( + key: const ValueKey('create-task-auto-extract'), + value: _autoExtract ?? false, + onChanged: (value) => setState(() => _autoExtract = value), + ), + ), + if (_autoExtract == true) ...[ + const SizedBox(height: 12), + _FormRow( + label: context.l10n.archivePassword, + child: _WindowTextField( + key: const ValueKey('create-task-archive-password'), + controller: _archivePasswordController, + hintText: context.l10n.archivePasswordHint, + obscureText: true, + ), + ), + const SizedBox(height: 12), + _FormRow( + label: context.l10n.deleteAfterExtract, + child: _OptionSwitch( + key: const ValueKey('create-task-delete-after-extract'), + value: _deleteAfterExtract, + onChanged: (value) => setState(() => _deleteAfterExtract = value), + ), + ), + ], + ] else ...[ + _FormRow( + label: context.l10n.trackers, + child: SizedBox( + height: 96, + child: TextField( + controller: _trackersController, + hintText: context.l10n.oneTrackerPerLine, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + maxLines: null, + minLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), + ], + ), + ), + ), + Container( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.border)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SecondaryButton( + onPressed: _creating ? null : _closeWindow, + child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))), + ), + const SizedBox(width: 12), + AppPrimaryButton( + onPressed: _creating ? null : _confirm, + child: SizedBox( + width: 68, + child: Center( + child: _creating + ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) + : Text(context.l10n.confirm), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + + return page; + } + + Future _closeWindow() async { + if (widget.windowController == null && mounted) { + context.go('/'); + return; + } + await windowManager.close(); + } + + Future _loadDefaults() async { + try { + final config = await ref.read(gopeedServiceProvider).getConfig(); + if (!mounted) return; + setState(() { + _configuredDownloadDirectory = config.downloadDir.trim(); + if (config.downloadDir.isNotEmpty && + (_directoryController.text.isEmpty || _directoryController.text == 'C:/Users/levi/Downloads')) { + _directoryController.text = config.downloadDir; + } + if (_asDefaultPath && _sameDirectory(_directoryController.text, _configuredDownloadDirectory)) { + _asDefaultPath = false; + } + final connections = config.protocolConfig.http.connections; + if (connections > 0 && (_connectionsController.text.isEmpty || _connectionsController.text == '16')) { + _connectionsController.text = connections.toString(); + } + _directDownload = config.extra.defaultDirectDownload; + _downloadCategories = config.extra.downloadCategories + .where((category) => !category.isDeleted) + .toList(growable: false); + }); + } catch (_) { + // Keep local defaults when the backend is not available yet. + } + } + + Future _loadClipboardUrl() async { + try { + final data = await Clipboard.getData('text/plain'); + final value = data?.text?.trim() ?? ''; + if (!mounted || value.isEmpty || _urlController.text.trim().isNotEmpty) { + return; + } + final protocol = _parseProtocol(value); + if (protocol != null || _isBtHash(value)) { + _setUrlText(_normalizeTaskUrl(value)); + } + } catch (_) { + // Clipboard access is best-effort and can be denied on some platforms. + } + } + + Future _pickTorrentFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ['torrent'], + withData: kIsWeb, + ); + final file = result?.files.single; + if (file == null) return; + + await _applyTorrentFile(name: file.name, filePath: file.path, bytes: file.bytes); + } + + Future _handleDroppedTorrent(DropDoneDetails details) async { + if (details.files.isEmpty) { + return; + } + final file = details.files.first; + try { + await _applyTorrentFile(name: file.name, filePath: file.path, readBytes: () async => file.readAsBytes()); + } catch (error) { + if (mounted) { + _showToast(context.l10n.unableReadTorrent(error.toString())); + } + } + } + + Future _applyTorrentFile({ + required String name, + String? filePath, + List? bytes, + Future> Function()? readBytes, + }) async { + if (!name.toLowerCase().endsWith('.torrent')) { + _showToast(context.l10n.torrentOnly); + return; + } + + if (kIsWeb) { + final fileBytes = bytes ?? await readBytes?.call(); + if (!mounted) return; + if (fileBytes == null || fileBytes.isEmpty) { + _showToast(context.l10n.torrentDataEmpty); + return; + } + _setUrlText(name, fileDataUri: 'data:application/x-bittorrent;base64,${base64Encode(fileBytes)}'); + return; + } + + if (filePath == null || filePath.isEmpty) { + _showToast(context.l10n.torrentPathUnavailable); + return; + } + _setUrlText(filePath); + } + + Future _confirm() async { + if (_creating) { + return; + } + final urls = _inputUrls(); + if (urls.isEmpty) { + _showToast(context.l10n.downloadLinkValid); + return; + } + if (!_validateAdvancedOptions()) { + return; + } + + setState(() { + _creating = true; + _draggingTorrent = false; + }); + try { + await _saveCreateHistory(urls); + final isDirect = _directDownload || urls.length > 1; + if (isDirect) { + await Future.wait( + urls.map((url) { + return ref + .read(gopeedServiceProvider) + .createTask( + CreateTask( + req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)), + opts: _buildOptions(name: urls.length > 1 ? '' : _renameController.text.trim()), + ), + ); + }), + ); + await _closeWindow(); + return; + } + + final request = _buildRequest(urls.first, protocolUrl: _protocolUrlFor(urls.first)); + final options = _buildOptions(); + final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options)); + if (!mounted) return; + _syncResolvedName(result); + final created = await _showResolveDialog(request, result); + if (!created) { + if (mounted) { + setState(() => _creating = false); + } + return; + } + await _closeWindow(); + } catch (error) { + if (mounted) { + _showToast(error.toString()); + setState(() => _creating = false); + } + } + } + + List _inputUrls() { + return Util.textToLines( + _submitText().trim(), + ).map((line) => _normalizeTaskUrl(line.trim())).where((line) => line.isNotEmpty).toList(); + } + + String _submitText() { + return kIsWeb && _fileDataUri.isNotEmpty ? _fileDataUri : _urlController.text.trim(); + } + + String _protocolUrlFor(String url) { + return kIsWeb && _fileDataUri.isNotEmpty ? _urlController.text.trim() : url; + } + + Request _buildRequest(String url, {String? protocolUrl}) { + final protocol = _parseProtocol(protocolUrl ?? url); + Object? extra; + final headers = _httpHeadersController.toMap(requireValue: true); + switch (protocol) { + case _TaskProtocol.http: + if (headers.isNotEmpty || _httpMethod != 'GET' || _httpBody.isNotEmpty) { + extra = ReqExtraHttp(method: _httpMethod, header: headers, body: _httpBody).toJson(); + } + break; + case _TaskProtocol.bt: + final trackers = Util.textToLines( + _trackersController.text, + ).map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); + if (trackers.isNotEmpty) { + extra = ReqExtraBt(trackers: trackers).toJson(); + } + break; + case _TaskProtocol.ed2k: + case null: + break; + } + return Request( + rawUrl: _initialRawUrl, + url: url, + extra: extra, + labels: _initialLabels, + proxy: _buildProxy(), + skipVerifyCert: _skipVerifyCert, + ); + } + + bool _validateAdvancedOptions() { + if (_proxyMode != RequestProxyMode.custom) { + return true; + } + if (_proxyServerController.text.trim().isEmpty) { + _showToast(context.l10n.serverRequired); + return false; + } + final portText = _proxyPortController.text.trim(); + if (portText.isNotEmpty) { + final port = int.tryParse(portText); + if (port == null || port < 0 || port > 65535) { + _showToast(context.l10n.portRangeError); + return false; + } + } + return true; + } + + RequestProxy _buildProxy() { + if (_proxyMode != RequestProxyMode.custom) { + return RequestProxy(mode: _proxyMode); + } + var server = _proxyServerController.text.trim(); + final port = _proxyPortController.text.trim(); + if (server.contains(':') && !server.startsWith('[')) { + server = '[$server]'; + } + return RequestProxy( + mode: RequestProxyMode.custom, + scheme: _proxyScheme, + host: port.isEmpty ? server : '$server:$port', + usr: _proxyUsernameController.text.trim(), + pwd: _proxyPasswordController.text, + ); + } + + _TaskProtocol? _parseProtocol(String url) { + final uppercaseUrl = url.trim().toUpperCase(); + if (uppercaseUrl.startsWith('HTTP:') || uppercaseUrl.startsWith('HTTPS:')) { + return _TaskProtocol.http; + } + if (uppercaseUrl.startsWith('MAGNET:') || uppercaseUrl.endsWith('.TORRENT')) { + return _TaskProtocol.bt; + } + if (uppercaseUrl.startsWith('ED2K:')) { + return _TaskProtocol.ed2k; + } + return null; + } + + String _normalizeTaskUrl(String url) { + if (_isBtHash(url)) { + return 'magnet:?xt=urn:btih:$url'; + } + return url; + } + + void _recognizeMagnetUri(String text) { + if (!_isBtHash(text)) { + return; + } + final uri = _normalizeTaskUrl(text); + if (_urlController.text == uri) { + return; + } + _setUrlText(uri); + } + + bool _isBtHash(String text) { + return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text); + } + + api_options.Options _buildOptions({String? name, List selectFiles = const []}) { + final connections = int.tryParse(_connectionsController.text.trim()) ?? 0; + return api_options.Options( + name: name ?? _renameController.text.trim(), + path: _directoryController.text.trim(), + asDefaultPath: _asDefaultPath, + selectFiles: selectFiles, + extra: api_options.OptsExtraHttp( + connections: connections, + autoTorrent: _autoTorrent, + deleteTorrentAfterDownload: _deleteTorrentAfterDownload, + autoExtract: _autoExtract, + archivePassword: _archivePasswordController.text, + deleteAfterExtract: _deleteAfterExtract, + ).toJson(), + ); + } + + bool _sameDirectory(String left, String right) { + final normalizedLeft = left.trim(); + final normalizedRight = right.trim(); + if (normalizedLeft.isEmpty || normalizedRight.isEmpty) return false; + return path.equals(path.normalize(normalizedLeft), path.normalize(normalizedRight)); + } + + Future _submitResolved(Request request, ResolveResult result, List selectedIndexes) async { + final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList()) + ..sort(); + if (result.id.isNotEmpty) { + await ref + .read(gopeedServiceProvider) + .createTask( + CreateTask( + rid: result.id, + opts: _buildOptions(selectFiles: selected), + ), + ); + return; + } + + if (result.res.files.isNotEmpty) { + final reqs = selected.map((index) { + final file = result.res.files[index]; + return CreateTaskBatchItem( + req: file.req ?? request, + opts: api_options.Options( + name: file.name, + path: path.join(_directoryController.text.trim(), result.res.name, file.path), + extra: _buildOptions().extra, + ), + ); + }).toList(); + await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions())); + return; + } + + await ref.read(gopeedServiceProvider).createTask(CreateTask(req: request, opts: _buildOptions())); + } + + Future _showResolveDialog(Request request, ResolveResult result) async { + final initialSelection = List.generate(result.res.files.length, (index) => index); + var selectedIndexes = List.of(initialSelection); + var submitting = false; + String? errorText; + + final dialog = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: !submitting, + builder: (dialogContext) { + final palette = AppPalette.of(dialogContext); + final screenSize = MediaQuery.sizeOf(dialogContext); + final dialogWidth = screenSize.width < 760 ? screenSize.width - 32 : 720.0; + final treeHeight = screenSize.height < 700 ? screenSize.height * 0.55 : 440.0; + + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + Future submit() async { + if (result.res.files.isNotEmpty && selectedIndexes.isEmpty) { + setDialogState(() => errorText = dialogContext.l10n.selectAtLeastOneFile); + return; + } + setDialogState(() { + submitting = true; + errorText = null; + }); + try { + await _submitResolved(request, result, selectedIndexes); + if (dialogContext.mounted) { + await closeOverlay(dialogContext, true); + } + } catch (error) { + if (!dialogContext.mounted) return; + setDialogState(() { + submitting = false; + errorText = error.toString(); + }); + } + } + + return AlertDialog( + padding: const EdgeInsets.all(18), + title: Text( + result.res.name.trim().isEmpty ? dialogContext.l10n.resolvedFiles : result.res.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + content: SizedBox( + width: dialogWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.res.files.isEmpty + ? dialogContext.l10n.resourceHasNoFiles + : dialogContext.l10n.resolvedFileCount(result.res.files.length), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + const SizedBox(height: 12), + SizedBox( + height: treeHeight.clamp(260.0, 460.0), + child: result.res.files.isEmpty + ? Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.cardBg, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Text( + dialogContext.l10n.noFiles, + style: TextStyle(color: palette.textMuted, fontSize: 13), + ), + ) + : ResolveFileTree( + files: result.res.files, + initialSelection: initialSelection, + onSelectionChanged: (values) => selectedIndexes = values, + ), + ), + if (errorText != null) ...[ + const SizedBox(height: 10), + Text(errorText!, style: TextStyle(color: palette.error, fontSize: 12)), + ], + ], + ), + ), + actions: [ + SecondaryButton( + onPressed: submitting ? null : () => closeOverlay(dialogContext, false), + child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))), + ), + AppPrimaryButton( + onPressed: submitting ? null : submit, + child: SizedBox( + width: 68, + child: Center( + child: submitting + ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) + : Text(dialogContext.l10n.create), + ), + ), + ), + ], + ); + }, + ); + }, + ); + final created = await dialog.future; + return created == true; + } + + Future _showCreateHistory() async { + List histories; + try { + histories = List.of(await ref.read(appStorageServiceProvider).getCreateHistory()); + } catch (_) { + histories = const []; + } + if (!mounted) return; + final filterController = TextEditingController(); + var filterQuery = ''; + final dialog = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + builder: (dialogContext) { + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + final screenSize = MediaQuery.sizeOf(dialogContext); + final contentWidth = screenSize.width < 620 ? screenSize.width - 68 : 560.0; + final normalizedQuery = filterQuery.trim().toLowerCase(); + final visibleHistories = normalizedQuery.isEmpty + ? histories + : histories.where((value) => value.toLowerCase().contains(normalizedQuery)).toList(growable: false); + return AlertDialog( + padding: const EdgeInsets.all(18), + title: SizedBox( + key: const ValueKey('create-history-title-bar'), + width: contentWidth, + child: Row( + children: [ + Expanded(child: Text(dialogContext.l10n.createHistory)), + AppTooltip( + message: dialogContext.l10n.clearHistory, + child: GhostButton( + key: const ValueKey('create-history-clear'), + density: ButtonDensity.icon, + onPressed: histories.isEmpty + ? null + : () async { + await ref.read(appStorageServiceProvider).clearCreateHistory(); + if (!dialogContext.mounted) return; + filterController.clear(); + setDialogState(() { + histories = []; + filterQuery = ''; + }); + }, + child: const Icon(Icons.history_toggle_off_rounded, size: 17), + ), + ), + const SizedBox(width: 4), + AppTooltip( + message: dialogContext.l10n.close, + child: GhostButton( + key: const ValueKey('create-history-close'), + density: ButtonDensity.icon, + onPressed: () => closeOverlay(dialogContext), + child: const Icon(Icons.close_rounded, size: 18), + ), + ), + ], + ), + ), + content: SizedBox( + key: const ValueKey('create-history-content'), + width: contentWidth, + height: screenSize.height < 560 ? screenSize.height * 0.55 : 360, + child: Column( + children: [ + SizedBox( + height: 38, + child: TextField( + key: const ValueKey('create-history-filter'), + controller: filterController, + placeholder: Text( + dialogContext.l10n.searchHistory, + style: TextStyle(color: palette.searchHint, fontSize: 13), + ), + features: [ + InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted)), + ], + onChanged: (value) => setDialogState(() => filterQuery = value), + ), + ), + const SizedBox(height: 10), + Expanded( + child: visibleHistories.isEmpty + ? Center( + child: Text( + normalizedQuery.isEmpty + ? dialogContext.l10n.noHistory + : dialogContext.l10n.noHistoryFound, + style: TextStyle(color: palette.textMuted, fontSize: 13), + ), + ) + : ListView.separated( + itemCount: visibleHistories.length, + separatorBuilder: (_, _) => Container(height: 1, color: palette.border), + itemBuilder: (context, index) { + final value = visibleHistories[index]; + return Row( + children: [ + Expanded( + child: GestureDetector( + key: ValueKey('create-history-item-$value'), + behavior: HitTestBehavior.opaque, + onTap: () { + _setUrlText(value); + closeOverlay(dialogContext); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text( + softWrapAnywhere(value), + semanticsLabel: value, + style: TextStyle(color: palette.textPrimary, fontSize: 12), + ), + ), + ), + ), + AppTooltip( + message: dialogContext.l10n.delete, + child: GhostButton( + key: ValueKey('create-history-delete-$value'), + density: ButtonDensity.icon, + onPressed: () async { + await ref.read(appStorageServiceProvider).removeCreateHistory(value); + if (!dialogContext.mounted) return; + setDialogState(() => histories.remove(value)); + }, + child: Icon(Icons.delete_outline_rounded, size: 16, color: palette.textMuted), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + await dialog.future; + filterController.dispose(); + } + + void _syncResolvedName(ResolveResult result) { + if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) { + _renameController.text = result.res.name; + } + } + + void _setUrlText(String value, {String fileDataUri = ''}) { + _programmaticUrlChange = true; + _urlController.text = value; + _urlController.selection = TextSelection.fromPosition(TextPosition(offset: value.length)); + _programmaticUrlChange = false; + if (!mounted) { + _fileDataUri = fileDataUri; + return; + } + setState(() { + _fileDataUri = fileDataUri; + if (fileDataUri.isNotEmpty || _parseProtocol(value) == _TaskProtocol.bt) { + _protocolTab = 1; + } + }); + } + + void _setDraggingTorrent(bool value) { + if (!mounted || _draggingTorrent == value) { + return; + } + setState(() => _draggingTorrent = value); + } + + Future _saveCreateHistory(List urls) async { + if (kIsWeb && _fileDataUri.isNotEmpty) { + return; + } + await ref.read(appStorageServiceProvider).saveCreateHistory(urls); + } + + String _categoryDisplayName(BuildContext context, DownloadCategory category) { + return switch (category.nameKey) { + 'categoryMusic' => context.l10n.categoryMusic, + 'categoryVideo' => context.l10n.categoryVideo, + 'categoryDocument' => context.l10n.categoryDocument, + 'categoryProgram' => context.l10n.categoryProgram, + _ => category.name, + }; + } + + String _renderPathPlaceholders(String value) { + final now = DateTime.now(); + final month = now.month.toString().padLeft(2, '0'); + final day = now.day.toString().padLeft(2, '0'); + return value + .replaceAll('%year%', now.year.toString()) + .replaceAll('%month%', month) + .replaceAll('%day%', day) + .replaceAll('%date%', '${now.year}-$month-$day'); + } + + void _showToast(String message) { + showAppToast(context, message, type: AppToastType.error); + } +} + +enum _TaskProtocol { http, bt, ed2k } + +class _FormRow extends StatelessWidget { + const _FormRow({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + label, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(width: 12), + Expanded(child: child), + ], + ); + } +} + +class _DirectDownloadToggle extends StatelessWidget { + const _DirectDownloadToggle({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + key: const ValueKey('create-task-direct-download-toggle'), + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(!value), + child: SizedBox( + height: 38, + child: Row( + children: [ + Checkbox( + key: const ValueKey('create-task-direct-download-checkbox'), + state: value ? CheckboxState.checked : CheckboxState.unchecked, + onChanged: (_) => onChanged(!value), + ), + const SizedBox(width: AppDesignTokens.checkboxLabelGap), + Expanded( + child: Text( + key: const ValueKey('create-task-direct-download-label'), + context.l10n.directDownloadDescription, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textMuted, fontSize: 12), + ), + ), + ], + ), + ), + ); + } +} + +class _AsDefaultPathToggle extends StatelessWidget { + const _AsDefaultPathToggle({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + key: const ValueKey('create-task-remember-download-directory'), + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(!value), + child: SizedBox( + height: 24, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + key: const ValueKey('create-task-remember-download-directory-checkbox'), + state: value ? CheckboxState.checked : CheckboxState.unchecked, + onChanged: (_) => onChanged(!value), + ), + const SizedBox(width: AppDesignTokens.checkboxLabelGap), + Flexible( + child: Text( + key: const ValueKey('create-task-remember-download-directory-label'), + context.l10n.rememberLastDownloadDir, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textMuted, fontSize: 12), + ), + ), + ], + ), + ), + ); + } +} + +class _DirectoryShortcut { + const _DirectoryShortcut({required this.index, required this.label, required this.path}); + + final int index; + final String label; + final String path; +} + +class _DirectoryOptions extends StatelessWidget { + const _DirectoryOptions({ + super.key, + required this.asDefaultPath, + required this.onAsDefaultPathChanged, + required this.shortcuts, + required this.onShortcutSelected, + }); + + final bool asDefaultPath; + final ValueChanged onAsDefaultPathChanged; + final List<_DirectoryShortcut> shortcuts; + final ValueChanged<_DirectoryShortcut> onShortcutSelected; + + @override + Widget build(BuildContext context) { + final stacked = shortcuts.isNotEmpty && MediaQuery.sizeOf(context).width < Breakpoints.mobile; + final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged); + final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected); + + if (stacked) { + return Column( + key: const ValueKey('create-task-directory-options-layout'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 30, + child: Align(alignment: Alignment.centerLeft, child: toggle), + ), + const SizedBox(height: 8), + shortcutList, + ], + ); + } + + return SizedBox( + height: 30, + child: flutter.Row( + key: const ValueKey('create-task-directory-options-layout'), + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + toggle, + if (shortcuts.isNotEmpty) + flutter.Flexible( + child: Padding(padding: const EdgeInsetsDirectional.only(start: 16), child: shortcutList), + ), + ], + ), + ); + } +} + +class _DirectoryShortcuts extends StatelessWidget { + const _DirectoryShortcuts({required this.shortcuts, required this.alignEnd, required this.onSelected}); + + final List<_DirectoryShortcut> shortcuts; + final bool alignEnd; + final ValueChanged<_DirectoryShortcut> onSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('create-task-directory-shortcuts'), + height: 28, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: alignEnd, + child: Row( + key: const ValueKey('create-task-directory-shortcuts-content'), + mainAxisSize: MainAxisSize.min, + children: [ + for (final shortcut in shortcuts) ...[ + if (shortcut.index != shortcuts.first.index) const SizedBox(width: 6), + _DirectoryShortcutButton( + key: ValueKey('create-task-category-${shortcut.index}'), + shortcut: shortcut, + onPressed: () => onSelected(shortcut), + ), + ], + ], + ), + ), + ); + } +} + +class _DirectoryShortcutButton extends StatefulWidget { + const _DirectoryShortcutButton({super.key, required this.shortcut, required this.onPressed}); + + final _DirectoryShortcut shortcut; + final VoidCallback onPressed; + + @override + State<_DirectoryShortcutButton> createState() => _DirectoryShortcutButtonState(); +} + +class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return AppTooltip( + message: widget.shortcut.path, + child: Semantics( + button: true, + label: widget.shortcut.label, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + height: 28, + constraints: const BoxConstraints(maxWidth: 168), + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: _hovered ? palette.surfaceSoft : palette.cardBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: Border.all(color: palette.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder_outlined, size: 14, color: palette.textMuted), + const SizedBox(width: 5), + Flexible( + child: Text( + widget.shortcut.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: 11.5, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _WindowTextField extends StatelessWidget { + const _WindowTextField({ + super.key, + required this.controller, + required this.hintText, + this.keyboardType, + this.inputFormatters, + this.obscureText = false, + }); + + final TextEditingController controller; + final String hintText; + final TextInputType? keyboardType; + final List? inputFormatters; + final bool obscureText; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return TextField( + controller: controller, + hintText: hintText, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + obscureText: obscureText, + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ); + } +} + +class _OptionSwitch extends StatelessWidget { + const _OptionSwitch({super.key, required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: Align( + alignment: Alignment.centerLeft, + child: Switch(value: value, onChanged: onChanged), + ), + ); + } +} + +class _MiniInputAction extends StatelessWidget { + const _MiniInputAction({required this.icon, this.onPressed}); + + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GhostButton( + density: ButtonDensity.icon, + onPressed: onPressed, + child: Icon(icon, size: 14, color: palette.textSecondary), + ); + } +} + +class _TripleChevronIcon extends StatelessWidget { + const _TripleChevronIcon({required this.expanded, required this.color}); + + final bool expanded; + final Color color; + + @override + Widget build(BuildContext context) { + final icon = expanded ? Icons.expand_less : Icons.expand_more; + return SizedBox( + width: 14, + height: 18, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned(top: 0, left: 0, child: Icon(icon, size: 14, color: color)), + Positioned(top: 4, left: 0, child: Icon(icon, size: 14, color: color)), + Positioned(top: 8, left: 0, child: Icon(icon, size: 14, color: color)), + ], + ), + ); + } +} + +class _SegmentButton extends StatelessWidget { + const _SegmentButton({required this.label, required this.active, required this.onTap}); + + final String label; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + decoration: BoxDecoration( + color: active ? palette.cardBg : const Color(0x00000000), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + +const _advancedExpandDuration = Duration(milliseconds: 220); +const _advancedScrollDelay = Duration(milliseconds: 90); +const _advancedScrollDuration = Duration(milliseconds: 260); +const _advancedScrollExtentRetryDelay = Duration(milliseconds: 40); +const _advancedScrollExtentChecks = 4; +const _advancedScrollDistance = 112.0; diff --git a/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart new file mode 100644 index 00000000..9fd4b4f0 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../core/utils/file_explorer.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/detail/app_detail_surface.dart'; +import '../../../../l10n/l10n.dart'; +import '../../application/tasks_controller.dart'; +import '../../domain/task_record.dart'; +import '../widgets/task_drawer.dart'; + +class TaskDetailsPage extends ConsumerWidget { + const TaskDetailsPage({super.key, required this.taskId, this.initialTask}); + + final String taskId; + final TaskRecord? initialTask; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tasksAsync = ref.watch(tasksControllerProvider); + TaskRecord? task = initialTask; + for (final item in tasksAsync.value?.tasks ?? const []) { + if (item.id == taskId) { + task = item; + break; + } + } + final palette = AppPalette.of(context); + + return AppDetailPage( + title: context.l10n.taskDetails, + onBack: () => context.canPop() ? context.pop() : context.go('/'), + child: task == null + ? Center( + child: tasksAsync.isLoading + ? const shad.CircularProgressIndicator() + : Text(context.l10n.taskNotFound, style: TextStyle(color: palette.textMuted, fontSize: 13)), + ) + : TaskDetailsView( + key: ValueKey(task.id), + task: task, + mobile: true, + onOpenStorage: () => FileExplorer.reveal(task!.storagePath), + onUpdateUrl: (url) async { + try { + await ref + .read(tasksControllerProvider.notifier) + .updateUrl(task!.id, url, headers: task.requestHeaders); + } catch (error) { + if (context.mounted) { + showAppToast(context, error.toString(), type: AppToastType.error); + } + rethrow; + } + }, + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart new file mode 100644 index 00000000..a50eb5f6 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../application/tasks_controller.dart'; +import '../../domain/task_record.dart'; +import '../widgets/task_file_manager.dart'; + +class TaskFilesPage extends ConsumerWidget { + const TaskFilesPage({super.key, required this.taskId, this.initialTask}); + + final String taskId; + final TaskRecord? initialTask; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tasksAsync = ref.watch(tasksControllerProvider); + TaskRecord? task = initialTask; + for (final item in tasksAsync.value?.tasks ?? const []) { + if (item.id == taskId) { + task = item; + break; + } + } + final palette = AppPalette.of(context); + + return shad.Scaffold( + backgroundColor: palette.bg, + child: ColoredBox( + color: palette.bg, + child: Column( + children: [ + SafeArea( + bottom: false, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + SizedBox( + width: 44, + child: AppTooltip( + message: context.l10n.close, + child: shad.IconButton.ghost( + onPressed: () => context.canPop() ? context.pop() : context.go('/tasks/$taskId'), + icon: Icon(Icons.arrow_back, size: 20, color: palette.textPrimary), + ), + ), + ), + Expanded( + child: Text( + task?.name ?? context.l10n.taskDetailsFilesTab, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + Expanded( + child: task == null + ? Center( + child: tasksAsync.isLoading + ? const shad.CircularProgressIndicator() + : Text(context.l10n.taskNotFound, style: TextStyle(color: palette.textMuted, fontSize: 13)), + ) + : TaskFileManagerView(task: task), + ), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/task_record_localizations.dart b/ui/flutter/lib/features/tasks/presentation/task_record_localizations.dart new file mode 100644 index 00000000..5c890e50 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/task_record_localizations.dart @@ -0,0 +1,50 @@ +import '../../../l10n/app_localizations.dart'; +import '../domain/task_record.dart'; + +extension TaskRecordLocalizations on TaskRecord { + String localizedStatus(AppLocalizations l10n) => switch (status) { + TaskStatus.downloading when waiting => l10n.waiting, + TaskStatus.downloading => l10n.downloading, + TaskStatus.completed => l10n.completed, + TaskStatus.paused => l10n.pause, + TaskStatus.failed => l10n.failed, + }; + + String localizedStatusUppercase(AppLocalizations l10n) => localizedStatus(l10n).toUpperCase(); + + String? localizedRemaining(AppLocalizations l10n) { + if (status == TaskStatus.completed) return null; + if (remaining case final value?) return value; + if (waiting) return l10n.waiting; + if (status == TaskStatus.paused) return l10n.pause; + final seconds = remainingSeconds; + if (seconds == null) return null; + return _formatTaskTime(l10n, seconds, remaining: true); + } + + String? localizedDownloadDuration(AppLocalizations l10n) { + final duration = downloadDuration; + if (duration == null) return null; + return formatTaskDuration(l10n, duration); + } +} + +String formatTaskDuration(AppLocalizations l10n, Duration duration) { + final microseconds = duration.inMicroseconds; + final seconds = microseconds <= 0 + ? 0 + : (microseconds + Duration.microsecondsPerSecond - 1) ~/ Duration.microsecondsPerSecond; + return _formatTaskTime(l10n, seconds, remaining: false); +} + +String _formatTaskTime(AppLocalizations l10n, int seconds, {required bool remaining}) { + if (seconds < Duration.secondsPerMinute) { + return remaining ? l10n.secondsRemaining(seconds) : l10n.secondsDuration(seconds); + } + if (seconds < Duration.secondsPerHour) { + final minutes = (seconds / Duration.secondsPerMinute).ceil(); + return remaining ? l10n.minutesRemaining(minutes) : l10n.minutesDuration(minutes); + } + final hours = (seconds / Duration.secondsPerHour).ceil(); + return remaining ? l10n.hoursRemaining(hours) : l10n.hoursDuration(hours); +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/pending_update_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/pending_update_dialog.dart new file mode 100644 index 00000000..f1f864a3 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/pending_update_dialog.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; + +enum PendingUpdateDecision { updateTask, createTask, cancel } + +Future showPendingUpdateDialog(BuildContext context, {required String taskName}) { + final dialog = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) { + final contentWidth = (MediaQuery.sizeOf(dialogContext).width - 64).clamp(240.0, 420.0); + return AlertDialog( + title: Text(dialogContext.l10n.pendingUpdateFound), + content: SizedBox( + width: contentWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(dialogContext.l10n.pendingUpdateConfirm(taskName)), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + SecondaryButton( + onPressed: () => closeOverlay(dialogContext, PendingUpdateDecision.cancel), + child: Text(dialogContext.l10n.cancel), + ), + SecondaryButton( + onPressed: () => closeOverlay(dialogContext, PendingUpdateDecision.createTask), + child: Text(dialogContext.l10n.pendingUpdateNo), + ), + AppPrimaryButton( + onPressed: () => closeOverlay(dialogContext, PendingUpdateDecision.updateTask), + child: Text(dialogContext.l10n.pendingUpdateYes), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + return dialog.future; +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart new file mode 100644 index 00000000..60e88399 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart @@ -0,0 +1,338 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; + +import '../../../../api/model/resource.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/file_tree_view.dart'; +import '../../../../util/util.dart'; + +class ResolveFileTree extends StatefulWidget { + const ResolveFileTree({ + super.key, + required this.files, + required this.initialSelection, + required this.onSelectionChanged, + }); + + final List files; + final List initialSelection; + final ValueChanged> onSelectionChanged; + + @override + State createState() => _ResolveFileTreeState(); +} + +class _ResolveFileTreeState extends State { + late List> _treeItems; + late List _allFileIndexes; + late final Set _selectedIndexes; + final Set<_FileTypeFilter> _activeTypeFilters = {}; + + @override + void initState() { + super.initState(); + _selectedIndexes = widget.initialSelection.where(_isValidIndex).toSet(); + _rebuildTreeItems(); + WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection()); + } + + @override + void didUpdateWidget(covariant ResolveFileTree oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(widget.files, oldWidget.files)) return; + _selectedIndexes.removeWhere((index) => !_isValidIndex(index)); + _activeTypeFilters.clear(); + _rebuildTreeItems(); + WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection()); + } + + @override + Widget build(BuildContext context) { + final selectedCount = _selectedIndexes.length; + final allSelected = _allFileIndexes.isNotEmpty && selectedCount == _allFileIndexes.length; + final partiallySelected = selectedCount > 0 && !allSelected; + final selectedSize = _selectedIndexes.fold(0, (sum, index) => sum + widget.files[index].size); + + return Column( + children: [ + Expanded( + child: FileTreeView( + items: _treeItems, + keyPrefix: 'resolve-tree', + headerLeading: ExcludeFocus( + child: Checkbox( + state: allSelected + ? CheckboxState.checked + : (partiallySelected ? CheckboxState.indeterminate : CheckboxState.unchecked), + onChanged: _allFileIndexes.isEmpty ? null : (_) => _setFileSelection(_allFileIndexes, !allSelected), + ), + ), + leadingBuilder: (context, node) { + final indexes = node.leafItems.map((item) => item.data).toList(growable: false); + final nodeSelectedCount = indexes.where(_selectedIndexes.contains).length; + final selected = indexes.isNotEmpty && nodeSelectedCount == indexes.length; + final partial = nodeSelectedCount > 0 && !selected; + return ExcludeFocus( + child: Checkbox( + state: selected + ? CheckboxState.checked + : (partial ? CheckboxState.indeterminate : CheckboxState.unchecked), + onChanged: (_) => _setFileSelection(indexes, !selected), + ), + ); + }, + onNodePressed: _toggleNodeSelection, + ), + ), + const SizedBox(height: 12), + _TreeFooter( + selectedCount: selectedCount, + totalCount: _allFileIndexes.length, + selectedSize: selectedSize, + unknownSize: selectedCount > 0 && selectedSize == 0, + activeTypeFilters: _activeTypeFilters, + onSelectVideo: () => _toggleTypeFilter(_FileTypeFilter.video), + onSelectAudio: () => _toggleTypeFilter(_FileTypeFilter.audio), + onSelectImage: () => _toggleTypeFilter(_FileTypeFilter.image), + ), + ], + ); + } + + bool _isValidIndex(int index) => index >= 0 && index < widget.files.length; + + void _rebuildTreeItems() { + _allFileIndexes = List.generate(widget.files.length, (index) => index, growable: false); + _treeItems = widget.files + .asMap() + .entries + .map( + (entry) => FileTreeItem( + key: entry.key.toString(), + path: entry.value.path, + name: entry.value.name, + size: entry.value.size, + data: entry.key, + ), + ) + .toList(growable: false); + } + + void _setFileSelection(Iterable indexes, bool selected) { + setState(() { + _activeTypeFilters.clear(); + if (selected) { + _selectedIndexes.addAll(indexes); + } else { + _selectedIndexes.removeAll(indexes); + } + }); + _notifySelection(); + } + + void _toggleNodeSelection(FileTreeNode node) { + final indexes = node.leafItems.map((item) => item.data).toList(growable: false); + final selected = indexes.isNotEmpty && indexes.every(_selectedIndexes.contains); + _setFileSelection(indexes, !selected); + } + + void _toggleTypeFilter(_FileTypeFilter filter) { + setState(() { + if (!_activeTypeFilters.remove(filter)) { + _activeTypeFilters.add(filter); + } + final extensions = {for (final activeFilter in _activeTypeFilters) ...activeFilter.extensions}; + _selectedIndexes + ..clear() + ..addAll( + widget.files + .asMap() + .entries + .where((entry) => extensions.contains(_extensionOf(entry.value.name))) + .map((entry) => entry.key), + ); + }); + _notifySelection(); + } + + void _notifySelection() { + final values = _selectedIndexes.toList()..sort(); + widget.onSelectionChanged(values); + } +} + +class _TreeFooter extends StatelessWidget { + const _TreeFooter({ + required this.selectedCount, + required this.totalCount, + required this.selectedSize, + required this.unknownSize, + required this.activeTypeFilters, + required this.onSelectVideo, + required this.onSelectAudio, + required this.onSelectImage, + }); + + final int selectedCount; + final int totalCount; + final int selectedSize; + final bool unknownSize; + final Set<_FileTypeFilter> activeTypeFilters; + final VoidCallback onSelectVideo; + final VoidCallback onSelectAudio; + final VoidCallback onSelectImage; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final filters = _FileTypeFilterGroup( + videoActive: activeTypeFilters.contains(_FileTypeFilter.video), + audioActive: activeTypeFilters.contains(_FileTypeFilter.audio), + imageActive: activeTypeFilters.contains(_FileTypeFilter.image), + onSelectVideo: onSelectVideo, + onSelectAudio: onSelectAudio, + onSelectImage: onSelectImage, + ); + final stats = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.selectedCount(selectedCount, totalCount), + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + Text( + unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize), + style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + ); + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 360) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align(alignment: Alignment.centerLeft, child: filters), + const SizedBox(height: 4), + Align(alignment: Alignment.centerRight, child: stats), + ], + ); + } + return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [filters, const Spacer(), stats]); + }, + ); + } +} + +class _FileTypeFilterGroup extends StatelessWidget { + const _FileTypeFilterGroup({ + required this.videoActive, + required this.audioActive, + required this.imageActive, + required this.onSelectVideo, + required this.onSelectAudio, + required this.onSelectImage, + }); + + final bool videoActive; + final bool audioActive; + final bool imageActive; + final VoidCallback onSelectVideo; + final VoidCallback onSelectAudio; + final VoidCallback onSelectImage; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _FilterButton( + key: const ValueKey('resolve-tree-filter-video'), + icon: Icons.movie_outlined, + active: videoActive, + onPressed: onSelectVideo, + ), + _FilterDivider(color: palette.border), + _FilterButton( + key: const ValueKey('resolve-tree-filter-audio'), + icon: Icons.audiotrack_outlined, + active: audioActive, + onPressed: onSelectAudio, + ), + _FilterDivider(color: palette.border), + _FilterButton( + key: const ValueKey('resolve-tree-filter-image'), + icon: Icons.image_outlined, + active: imageActive, + onPressed: onSelectImage, + ), + ], + ), + ); + } +} + +class _FilterDivider extends StatelessWidget { + const _FilterDivider({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox(width: 1, height: 18, child: ColoredBox(color: color)); + } +} + +class _FilterButton extends StatelessWidget { + const _FilterButton({super.key, required this.icon, required this.active, required this.onPressed}); + + final IconData icon; + final bool active; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return SizedBox( + width: 40, + height: 28, + child: DecoratedBox( + decoration: BoxDecoration(color: active ? palette.filterActiveBg : null), + child: GhostButton( + density: ButtonDensity.compact, + onPressed: onPressed, + child: Icon(icon, size: 14, color: active ? palette.brand : palette.textSecondary), + ), + ), + ); + } +} + +String _extensionOf(String name) { + final index = name.lastIndexOf('.'); + if (index < 0 || index == name.length - 1) return ''; + return name.substring(index + 1).toLowerCase(); +} + +enum _FileTypeFilter { video, audio, image } + +extension on _FileTypeFilter { + Set get extensions => switch (this) { + _FileTypeFilter.video => _videoExts, + _FileTypeFilter.audio => _audioExts, + _FileTypeFilter.image => _imageExts, + }; +} + +const _videoExts = {'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'}; +const _audioExts = {'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'}; +const _imageExts = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'}; diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/speed_monitor_card.dart b/ui/flutter/lib/features/tasks/presentation/widgets/speed_monitor_card.dart new file mode 100644 index 00000000..29333807 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/speed_monitor_card.dart @@ -0,0 +1,360 @@ +import 'dart:math'; + +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../core/utils/transfer_rate_formatter.dart'; +import '../../../../shared/theme/app_palette.dart'; + +class SpeedMonitorCard extends StatefulWidget { + const SpeedMonitorCard({super.key, this.downloadBytesPerSecond = 0, this.uploadBytesPerSecond = 0}); + + final int downloadBytesPerSecond; + final int uploadBytesPerSecond; + + @override + State createState() => _SpeedMonitorCardState(); +} + +class _SpeedMonitorCardState extends State with SingleTickerProviderStateMixin { + static const _needleSpring = SpringDescription(mass: 1, stiffness: 140, damping: 11); + static const int _sampleWindowSize = 30; + static const double _maxNeedleLaunchVelocity = 4.4; + static const double _minVelocityFloor = 0.18; + static const double _displayHeadroom = 1.2; + static const double _minDisplayMax = 12; + static const double _maxDisplayMax = 100; + static const double _displayDecayFactor = 0.965; + static const double _highPressureThreshold = 0.9; + static const int _highPressureWindow = 2; + static const double _highPressureBoost = 1.12; + + final List _downloadSamples = []; + late final AnimationController _needleController; + late double _download; + late double _displayMax; + int _highPressureStreak = 0; + + @override + void initState() { + super.initState(); + _download = _toMegabytesPerSecond(widget.downloadBytesPerSecond); + _displayMax = max(_minDisplayMax, _download * _displayHeadroom); + _primeSamples(_download); + _needleController = AnimationController.unbounded(vsync: this, value: (_download / _displayMax).clamp(0.0, 1.0)) + ..addListener(() { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void didUpdateWidget(SpeedMonitorCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.downloadBytesPerSecond == widget.downloadBytesPerSecond) { + return; + } + _setDownloadSpeed(_toMegabytesPerSecond(widget.downloadBytesPerSecond)); + } + + void _setDownloadSpeed(double download) { + final previousDownload = _download; + final nextDisplayMax = _resolveDisplayMax(download); + _download = download; + _displayMax = nextDisplayMax; + final target = (download / nextDisplayMax).clamp(0.0, 1.0); + final launchVelocity = _computeNeedleLaunchVelocity(previousDownload: previousDownload, nextDownload: download); + _needleController.animateWith(SpringSimulation(_needleSpring, _needleController.value, target, launchVelocity)); + } + + void _primeSamples(double initialValue) { + _downloadSamples + ..clear() + ..add(initialValue); + _highPressureStreak = 0; + } + + double _resolveDisplayMax(double currentDownload) { + _downloadSamples.add(currentDownload); + if (_downloadSamples.length > _sampleWindowSize) { + _downloadSamples.removeAt(0); + } + + final currentUsage = _displayMax <= 0 ? 0.0 : (currentDownload / _displayMax).clamp(0.0, 2.0); + if (currentUsage >= _highPressureThreshold) { + _highPressureStreak += 1; + } else { + _highPressureStreak = 0; + } + + final sorted = List.from(_downloadSamples)..sort(); + final p95Index = ((sorted.length - 1) * 0.95).round().clamp(0, sorted.length - 1); + final percentile95 = sorted[p95Index]; + var targetMax = (percentile95 * _displayHeadroom).clamp(_minDisplayMax, _maxDisplayMax); + + if (_highPressureStreak >= _highPressureWindow) { + targetMax = max(targetMax, (_displayMax * _highPressureBoost).clamp(_minDisplayMax, _maxDisplayMax)); + } + + if (targetMax >= _displayMax) { + return targetMax; + } + + // Expand quickly, contract slowly, so the gauge does not jitter when + // bandwidth fluctuates downward for a moment. + return max(targetMax, _displayMax * _displayDecayFactor); + } + + double _computeNeedleLaunchVelocity({required double previousDownload, required double nextDownload}) { + final delta = nextDownload - previousDownload; + if (delta.abs() < 0.01) { + return 0; + } + + if (delta < 0) { + // Falling speeds should settle quickly instead of overshooting downward. + return min(_needleController.velocity, 0) * 0.22; + } + + final combined = previousDownload + nextDownload; + final relativeJump = combined <= 0 ? 1.0 : delta / combined; + final absoluteJump = delta / 100.0; + + // Relative jump adapts to slow and fast networks; absolute jump prevents + // large moves at high speeds from feeling underpowered. + final jumpFactor = (relativeJump * 0.72) + (sqrt(absoluteJump.clamp(0.0, 1.0)) * 0.28); + final normalized = jumpFactor.clamp(0.0, 1.0); + final launchVelocity = _minVelocityFloor + (_maxNeedleLaunchVelocity - _minVelocityFloor) * normalized; + + // Keep a little of the current momentum so rapid consecutive spikes feel continuous. + final carriedMomentum = max(_needleController.velocity, 0) * 0.2; + return min(_maxNeedleLaunchVelocity, max(launchVelocity, carriedMomentum)); + } + + @override + void dispose() { + _needleController.dispose(); + super.dispose(); + } + + double _toMegabytesPerSecond(int bytesPerSecond) => max(0, bytesPerSecond) / (1024 * 1024); + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final gaugeForeground = palette.textPrimary; + final arcColor = gaugeForeground; + final needleColor = gaugeForeground; + final trackColor = gaugeForeground.withValues(alpha: 0.12); + final tickColor = gaugeForeground.withValues(alpha: 0.18); + Widget buildGauge() { + return SizedBox( + key: const ValueKey('speed-monitor-gauge'), + width: 60, + height: 32, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: (_download / _displayMax).clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 650), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + return CustomPaint( + painter: _SpeedGaugePainter( + value: value.clamp(0.0, 1.0), + needleValue: _needleController.value.clamp(-0.08, 1.08), + arcColor: arcColor, + needleColor: needleColor, + trackColor: trackColor, + tickColor: tickColor, + ), + ); + }, + ), + ); + } + + Widget buildSpeedLines({required bool compact}) { + return Column( + key: const ValueKey('speed-monitor-values'), + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _SpeedLine( + key: const ValueKey('speed-monitor-download-line'), + icon: Icons.south, + bytesPerSecond: widget.downloadBytesPerSecond, + foreground: palette.textPrimary, + unitColor: palette.textSecondary, + compact: compact, + valueSize: compact ? 12 : 13, + ), + SizedBox(height: compact ? 3 : 4), + _SpeedLine( + key: const ValueKey('speed-monitor-upload-line'), + icon: Icons.north, + bytesPerSecond: widget.uploadBytesPerSecond, + foreground: palette.textSecondary, + unitColor: palette.textMuted, + compact: compact, + valueSize: compact ? 10 : 11, + ), + ], + ); + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.fromLTRB(8, 0, 8, 24), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + decoration: BoxDecoration( + color: palette.cardBg.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 170) { + final gauge = buildGauge(); + return Row( + children: [ + gauge, + const SizedBox(width: 6), + Expanded(child: buildSpeedLines(compact: true)), + ], + ); + } + final gauge = buildGauge(); + return Row( + children: [ + gauge, + const SizedBox(width: 14), + Expanded(child: buildSpeedLines(compact: false)), + ], + ); + }, + ), + ); + } +} + +class _SpeedLine extends StatelessWidget { + const _SpeedLine({ + super.key, + required this.icon, + required this.bytesPerSecond, + required this.foreground, + required this.unitColor, + this.valueSize = 13, + this.compact = false, + }); + + final IconData icon; + final int bytesPerSecond; + final Color foreground; + final Color unitColor; + final double valueSize; + final bool compact; + + @override + Widget build(BuildContext context) { + final display = TransferRateFormatter.format(bytesPerSecond); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: compact ? 8 : 10, color: foreground), + SizedBox(width: compact ? 2 : 4), + Text( + display.value, + maxLines: 1, + style: TextStyle(color: foreground, fontSize: valueSize, fontWeight: FontWeight.w700, height: 1), + ), + SizedBox(width: compact ? 2 : 4), + Text( + display.unit, + maxLines: 1, + style: TextStyle( + color: unitColor, + fontSize: compact ? 8 : 9, + fontWeight: FontWeight.w600, + letterSpacing: compact ? 0 : 1.2, + height: 1, + ), + ), + ], + ); + } +} + +class _SpeedGaugePainter extends CustomPainter { + const _SpeedGaugePainter({ + required this.value, + required this.needleValue, + required this.arcColor, + required this.needleColor, + required this.trackColor, + required this.tickColor, + }); + + final double value; + final double needleValue; + final Color arcColor; + final Color needleColor; + final Color trackColor; + final Color tickColor; + + @override + void paint(Canvas canvas, Size size) { + final radius = min(size.width / 2 - 6, size.height - 6); + final center = Offset(size.width / 2, (size.height + radius) / 2); + final rect = Rect.fromCircle(center: center, radius: radius); + final tickPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 1.4 + ..color = tickColor; + final trackPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..color = trackColor; + final activePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..color = arcColor; + + const tickCount = 5; + for (var i = 0; i < tickCount; i++) { + final t = i / (tickCount - 1); + final tickAngle = pi + (pi * t); + final outerRadius = radius - 2; + final innerRadius = outerRadius - 4; + final start = Offset(center.dx + cos(tickAngle) * innerRadius, center.dy + sin(tickAngle) * innerRadius); + final end = Offset(center.dx + cos(tickAngle) * outerRadius, center.dy + sin(tickAngle) * outerRadius); + canvas.drawLine(start, end, tickPaint); + } + + canvas.drawArc(rect, pi, pi, false, trackPaint); + canvas.drawArc(rect, pi, pi * value, false, activePaint); + + final angle = pi + (pi * needleValue); + final needleEnd = Offset(center.dx + cos(angle) * (radius - 6), center.dy + sin(angle) * (radius - 6)); + final needlePaint = Paint() + ..color = needleColor + ..strokeWidth = 1.6 + ..strokeCap = StrokeCap.round; + canvas.drawLine(center, needleEnd, needlePaint); + canvas.drawCircle(center, 2.6, Paint()..color = needleColor); + } + + @override + bool shouldRepaint(covariant _SpeedGaugePainter oldDelegate) { + return oldDelegate.value != value || + oldDelegate.needleValue != needleValue || + oldDelegate.arcColor != arcColor || + oldDelegate.needleColor != needleColor || + oldDelegate.trackColor != trackColor || + oldDelegate.tickColor != tickColor; + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_batch_selection_builder.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_batch_selection_builder.dart new file mode 100644 index 00000000..664009da --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_batch_selection_builder.dart @@ -0,0 +1,69 @@ +import 'package:flutter/widgets.dart'; + +import '../../application/task_batch_selection_controller.dart'; + +typedef TaskBatchSelectionWidgetBuilder = Widget Function(BuildContext context, bool selected, bool allSelected); + +class TaskBatchSelectionBuilder extends StatefulWidget { + const TaskBatchSelectionBuilder({ + super.key, + required this.controller, + required this.taskId, + required this.visibleTaskIds, + required this.builder, + }); + + final TaskBatchSelectionController controller; + final String taskId; + final List visibleTaskIds; + final TaskBatchSelectionWidgetBuilder builder; + + @override + State createState() => _TaskBatchSelectionBuilderState(); +} + +class _TaskBatchSelectionBuilderState extends State { + late bool _selected; + late bool _allSelected; + + @override + void initState() { + super.initState(); + _readSelection(); + widget.controller.addListener(_handleSelectionChanged); + } + + @override + void didUpdateWidget(TaskBatchSelectionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_handleSelectionChanged); + widget.controller.addListener(_handleSelectionChanged); + } + _readSelection(); + } + + @override + void dispose() { + widget.controller.removeListener(_handleSelectionChanged); + super.dispose(); + } + + void _readSelection() { + _selected = widget.controller.contains(widget.taskId); + _allSelected = widget.controller.areAllSelected(widget.visibleTaskIds); + } + + void _handleSelectionChanged() { + final selected = widget.controller.contains(widget.taskId); + final allSelected = widget.controller.areAllSelected(widget.visibleTaskIds); + if (selected == _selected && allSelected == _allSelected) return; + setState(() { + _selected = selected; + _allSelected = allSelected; + }); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _selected, _allSelected); +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart new file mode 100644 index 00000000..9a65e243 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../core/utils/breakpoints.dart'; +import '../../../domain/task_record.dart'; +import '../task_context_menu.dart'; +import 'task_card_desktop.dart'; +import 'task_card_mobile.dart'; + +class TaskCard extends StatelessWidget { + const TaskCard({ + super.key, + required this.task, + required this.selected, + required this.batchMode, + required this.selectedInBatch, + required this.onPressed, + required this.onToggleBatch, + this.onPause, + this.onResume, + this.onDelete, + this.onOpen, + this.onReveal, + this.contextActions, + this.listeningForUpdate = false, + }); + + final TaskRecord task; + final bool selected; + final bool batchMode; + final bool selectedInBatch; + final VoidCallback onPressed; + final VoidCallback onToggleBatch; + final VoidCallback? onPause; + final VoidCallback? onResume; + final VoidCallback? onDelete; + final VoidCallback? onOpen; + final VoidCallback? onReveal; + final TaskContextActions? contextActions; + final bool listeningForUpdate; + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile; + + if (isMobile) { + final card = TaskCardMobile( + task: task, + selected: selected, + batchMode: batchMode, + selectedInBatch: selectedInBatch, + onPressed: onPressed, + onToggleBatch: onToggleBatch, + onPause: onPause, + onResume: onResume, + onDelete: onDelete, + onOpen: onOpen, + onReveal: onReveal, + listeningForUpdate: listeningForUpdate, + ); + final actions = contextActions; + return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card); + } + + final card = TaskCardDesktop( + task: task, + selected: selected, + batchMode: batchMode, + selectedInBatch: selectedInBatch, + onPressed: onPressed, + onToggleBatch: onToggleBatch, + onPause: onPause, + onResume: onResume, + onDelete: onDelete, + onOpen: onOpen, + onReveal: onReveal, + listeningForUpdate: listeningForUpdate, + ); + final actions = contextActions; + return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart new file mode 100644 index 00000000..1dded739 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart @@ -0,0 +1,323 @@ +import 'package:flutter/gestures.dart' show TapUpDetails, kDoubleTapSlop, kDoubleTapTimeout; +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../../shared/theme/app_design_tokens.dart'; +import '../../../../../shared/theme/app_palette.dart'; +import '../../../domain/task_record.dart'; +import '../task_progress_bar.dart'; +import 'task_card_parts.dart'; + +class TaskCardDesktop extends StatefulWidget { + const TaskCardDesktop({ + super.key, + required this.task, + required this.selected, + required this.batchMode, + required this.selectedInBatch, + required this.onPressed, + required this.onToggleBatch, + this.onPause, + this.onResume, + this.onDelete, + this.onOpen, + this.onReveal, + this.listeningForUpdate = false, + }); + + final TaskRecord task; + final bool selected; + final bool batchMode; + final bool selectedInBatch; + final VoidCallback onPressed; + final VoidCallback onToggleBatch; + final VoidCallback? onPause; + final VoidCallback? onResume; + final VoidCallback? onDelete; + final VoidCallback? onOpen; + final VoidCallback? onReveal; + final bool listeningForUpdate; + + @override + State createState() => _TaskCardDesktopState(); +} + +class _TaskCardDesktopState extends State { + bool _hovered = false; + DateTime? _lastTapTime; + Offset? _lastTapPosition; + + void _handleTap(TapUpDetails details) { + final now = DateTime.now(); + final lastTapTime = _lastTapTime; + final lastTapPosition = _lastTapPosition; + final isDoubleTap = + !widget.batchMode && + widget.task.status == TaskStatus.completed && + lastTapTime != null && + now.difference(lastTapTime) <= kDoubleTapTimeout && + lastTapPosition != null && + (details.globalPosition - lastTapPosition).distance <= kDoubleTapSlop; + + _lastTapTime = isDoubleTap ? null : now; + _lastTapPosition = isDoubleTap ? null : details.globalPosition; + widget.onPressed(); + if (isDoubleTap) { + widget.onOpen?.call(); + } + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final task = widget.task; + final isDark = shad.Theme.of(context).brightness == Brightness.dark; + final isError = task.status == TaskStatus.failed; + final borderColor = isDark ? palette.border : palette.border.withValues(alpha: 0.6); + final baseColor = isDark ? palette.cardBg : palette.cardBg.withValues(alpha: 0.92); + final hoverColor = palette.taskCardHoverBg; + final radius = BorderRadius.circular(AppDesignTokens.controlRadius); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTapUp: _handleTap, + child: Container( + height: AppDesignTokens.taskRowHeight, + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: widget.selected + ? [BoxShadow(color: palette.taskCardSelectedBorder, blurRadius: 0, spreadRadius: 1)] + : null, + ), + child: ClipRRect( + borderRadius: radius, + clipBehavior: Clip.hardEdge, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: baseColor), + if (_hovered) + IgnorePointer( + child: ColoredBox(key: const ValueKey('task-card-hover-surface'), color: hoverColor), + ), + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: _hovered ? palette.border : borderColor), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: widget.batchMode ? 36 : 0, + child: widget.batchMode + ? Align( + alignment: Alignment.centerLeft, + child: shad.Checkbox( + state: widget.selectedInBatch + ? shad.CheckboxState.checked + : shad.CheckboxState.unchecked, + onChanged: (_) => widget.onToggleBatch(), + borderColor: palette.border, + backgroundColor: palette.cardBg, + size: 20, + ), + ) + : const SizedBox.shrink(), + ), + TaskCardIcon(task: task), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + height: 46, + child: task.status == TaskStatus.completed + ? _CompletedContent( + task: task, + isDark: isDark, + onReveal: widget.onReveal, + onDelete: widget.onDelete, + listeningForUpdate: widget.listeningForUpdate, + ) + : _ProgressContent( + task: task, + isError: isError, + isDark: isDark, + onPause: widget.onPause, + onResume: widget.onResume, + onDelete: widget.onDelete, + listeningForUpdate: widget.listeningForUpdate, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ProgressContent extends StatelessWidget { + const _ProgressContent({ + required this.task, + required this.isError, + required this.isDark, + this.onPause, + this.onResume, + this.onDelete, + required this.listeningForUpdate, + }); + + final TaskRecord task; + final bool isError; + final bool isDark; + final VoidCallback? onPause; + final VoidCallback? onResume; + final VoidCallback? onDelete; + final bool listeningForUpdate; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final fillColor = taskCardFillColor(palette, task.status, isDark); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 21, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + task.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, height: 1), + ), + ), + const SizedBox(width: 16), + if (listeningForUpdate) ...[const TaskUpdateListeningIndicator(), const SizedBox(width: 8)], + Row(mainAxisSize: MainAxisSize.min, children: _buildActionButtons(task, palette, isDark)), + ], + ), + ), + const SizedBox(height: 6), + TaskProgressBar( + value: task.progress, + indeterminate: task.isIndeterminate, + trackColor: palette.brandTrack, + fillColor: fillColor, + highlightStartColor: fillColor, + highlightEndColor: fillColor, + shimmer: task.status == TaskStatus.downloading, + ), + const SizedBox(height: 6), + TaskCardFooter(task: task, isError: isError), + ], + ); + } + + List _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) { + final actions = switch (task.status) { + TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)], + TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)], + TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)], + TaskStatus.completed => [(Icons.folder_open_outlined, null), (Icons.delete_outline, onDelete)], + }; + + return actions + .map( + (action) => TaskCardActionButton( + icon: action.$1, + color: palette.taskCardAction, + onPressed: action.$2 ?? () {}, + hoverBackground: isDark ? palette.border : Colors.transparent, + ), + ) + .toList(growable: false); + } +} + +class _CompletedContent extends StatelessWidget { + const _CompletedContent({ + required this.task, + required this.isDark, + required this.listeningForUpdate, + this.onReveal, + this.onDelete, + }); + + final TaskRecord task; + final bool isDark; + final VoidCallback? onReveal; + final VoidCallback? onDelete; + final bool listeningForUpdate; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 21, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + task.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, height: 1), + ), + ), + const SizedBox(width: 16), + if (listeningForUpdate) ...[const TaskUpdateListeningIndicator(), const SizedBox(width: 8)], + Row(mainAxisSize: MainAxisSize.min, children: _buildActionButtons(task, palette, isDark)), + ], + ), + ), + const SizedBox(height: 6), + TaskCardCompletedMeta(task: task), + ], + ); + } + + List _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) { + final actions = switch (task.status) { + TaskStatus.downloading => [(Icons.pause, null), (Icons.close, onDelete)], + TaskStatus.paused => [(Icons.play_arrow, null), (Icons.close, onDelete)], + TaskStatus.failed => [(Icons.refresh, null), (Icons.delete_outline, onDelete)], + TaskStatus.completed => [(Icons.folder_open_outlined, onReveal), (Icons.delete_outline, onDelete)], + }; + + return actions + .map( + (action) => TaskCardActionButton( + icon: action.$1, + color: palette.taskCardAction, + onPressed: action.$2 ?? () {}, + hoverBackground: isDark ? palette.border : Colors.transparent, + ), + ) + .toList(growable: false); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart new file mode 100644 index 00000000..2700956b --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../../shared/theme/app_design_tokens.dart'; +import '../../../../../shared/theme/app_palette.dart'; +import '../../../domain/task_record.dart'; +import '../task_progress_bar.dart'; +import 'task_card_parts.dart'; + +class TaskCardMobile extends StatefulWidget { + const TaskCardMobile({ + super.key, + required this.task, + required this.selected, + required this.batchMode, + required this.selectedInBatch, + required this.onPressed, + required this.onToggleBatch, + this.onPause, + this.onResume, + this.onDelete, + this.onOpen, + this.onReveal, + this.listeningForUpdate = false, + }); + + final TaskRecord task; + final bool selected; + final bool batchMode; + final bool selectedInBatch; + final VoidCallback onPressed; + final VoidCallback onToggleBatch; + final VoidCallback? onPause; + final VoidCallback? onResume; + final VoidCallback? onDelete; + final VoidCallback? onOpen; + final VoidCallback? onReveal; + final bool listeningForUpdate; + + @override + State createState() => _TaskCardMobileState(); +} + +class _TaskCardMobileState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final isDark = shad.Theme.of(context).brightness == Brightness.dark; + final borderColor = isDark ? palette.border : palette.border.withValues(alpha: 0.6); + final baseColor = isDark ? palette.cardBg : palette.cardBg.withValues(alpha: 0.92); + final hoverColor = palette.taskCardHoverBg; + final radius = BorderRadius.circular(AppDesignTokens.controlRadius); + final task = widget.task; + final fillColor = taskCardFillColor(palette, task.status, isDark); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onPressed, + onDoubleTap: !widget.batchMode && task.status == TaskStatus.completed ? widget.onOpen : null, + child: Container( + key: const ValueKey('task-card-mobile-surface'), + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: widget.selected + ? [BoxShadow(color: palette.taskCardSelectedBorder, blurRadius: 0, spreadRadius: 1)] + : null, + ), + child: ClipRRect( + borderRadius: radius, + clipBehavior: Clip.hardEdge, + child: Stack( + children: [ + Positioned.fill(child: ColoredBox(color: baseColor)), + if (_hovered) + Positioned.fill( + child: IgnorePointer( + child: ColoredBox(key: const ValueKey('task-card-hover-surface'), color: hoverColor), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: _hovered ? palette.border : borderColor), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + child: widget.batchMode + ? Padding( + padding: const EdgeInsets.only(right: AppDesignTokens.checkboxLabelGap), + child: shad.Checkbox( + state: widget.selectedInBatch + ? shad.CheckboxState.checked + : shad.CheckboxState.unchecked, + onChanged: (_) => widget.onToggleBatch(), + borderColor: palette.border, + backgroundColor: palette.cardBg, + ), + ) + : const SizedBox.shrink(), + ), + TaskCardIcon(task: task), + const SizedBox(width: 12), + Expanded( + child: Text( + task.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.1, + ), + ), + ), + const SizedBox(width: 8), + if (widget.listeningForUpdate) ...[ + const TaskUpdateListeningIndicator(), + const SizedBox(width: 6), + ], + if (!widget.batchMode) ..._buildActionButtons(task, palette, isDark), + ], + ), + const SizedBox(height: 8), + if (task.status != TaskStatus.completed) ...[ + TaskProgressBar( + value: task.progress, + indeterminate: task.isIndeterminate, + trackColor: palette.brandTrack, + fillColor: fillColor, + highlightStartColor: fillColor, + highlightEndColor: fillColor, + shimmer: task.status == TaskStatus.downloading, + ), + const SizedBox(height: 6), + TaskCardFooter(task: task, isError: task.status == TaskStatus.failed), + ] else ...[ + TaskCardCompletedMeta(task: task), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + List _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) { + final actions = switch (task.status) { + TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)], + TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)], + TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)], + TaskStatus.completed => [(Icons.folder_open_outlined, widget.onReveal), (Icons.delete_outline, widget.onDelete)], + }; + + return actions + .map( + (action) => TaskCardActionButton( + icon: action.$1, + color: palette.taskCardAction, + onPressed: action.$2 ?? () {}, + hoverBackground: isDark ? palette.border : Colors.transparent, + buttonSize: 20, + iconSize: 13, + ), + ) + .toList(growable: false); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart new file mode 100644 index 00000000..19b34abe --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; + +import '../../../domain/task_record.dart'; +import '../../../../../shared/theme/app_design_tokens.dart'; +import '../../../../../shared/theme/app_palette.dart'; +import '../../../../../shared/widgets/app_tooltip.dart'; +import '../../../../../l10n/l10n.dart'; +import '../../task_record_localizations.dart'; + +Color taskCardFillColor(AppPalette palette, TaskStatus status, bool isDark) { + if (isDark) { + switch (status) { + case TaskStatus.failed: + return palette.error; + case TaskStatus.paused: + return palette.textMuted; + case TaskStatus.downloading: + case TaskStatus.completed: + return palette.brandProgress; + } + } + + switch (status) { + case TaskStatus.failed: + return palette.error; + case TaskStatus.paused: + return palette.textMuted; + case TaskStatus.downloading: + case TaskStatus.completed: + return palette.brandProgress; + } +} + +class TaskUpdateListeningIndicator extends StatelessWidget { + const TaskUpdateListeningIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return AppTooltip( + message: context.l10n.waitingForReplacementUrl, + child: Icon(Icons.hearing, size: 15, color: palette.brandProgress), + ); + } +} + +class TaskCardIcon extends StatelessWidget { + const TaskCardIcon({super.key, required this.task}); + + final TaskRecord task; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + + return SizedBox( + width: 32, + height: 32, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + clipBehavior: Clip.hardEdge, + child: ColoredBox( + color: palette.taskCardIconBg, + child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)), + ), + ), + ); + } +} + +class TaskCardFooter extends StatelessWidget { + const TaskCardFooter({super.key, required this.task, required this.isError}); + + final TaskRecord task; + final bool isError; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final metaColor = palette.taskMeta; + final leftColor = isError ? palette.error : metaColor; + final rightColor = isError ? palette.taskErrorMeta : metaColor; + final statusText = switch (task.status) { + TaskStatus.downloading => task.localizedRemaining(context.l10n) ?? context.l10n.downloading, + TaskStatus.paused => task.localizedRemaining(context.l10n) ?? context.l10n.pause, + TaskStatus.completed => context.l10n.completed, + TaskStatus.failed => context.l10n.failed, + }; + + return SizedBox( + height: 10, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + task.total != null ? '${task.downloaded} / ${task.total}' : context.l10n.unknownSize, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: leftColor, fontSize: 10, height: 1), + ), + ), + const SizedBox(width: 12), + if (task.speed != null && !isError) ...[ + _TaskTransferSpeed(icon: Icons.south, value: task.speed!, color: metaColor), + const SizedBox(width: 8), + TickDivider(color: palette.progressTrack), + const SizedBox(width: 8), + ], + if (task.uploading) ...[ + _TaskTransferSpeed(icon: Icons.north, value: task.uploadSpeed!, color: metaColor), + const SizedBox(width: 8), + TickDivider(color: palette.progressTrack), + const SizedBox(width: 8), + ], + Text( + statusText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: rightColor, fontSize: 10, height: 1), + ), + ], + ), + ); + } +} + +class TaskCardCompletedMeta extends StatelessWidget { + const TaskCardCompletedMeta({super.key, required this.task}); + + final TaskRecord task; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final metaColor = palette.taskMeta; + + return SizedBox( + height: 10, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + task.total ?? task.downloaded, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: metaColor, fontSize: 10, height: 1), + ), + ), + const SizedBox(width: 12), + if (task.speed != null) ...[ + _TaskTransferSpeed(icon: Icons.south, value: task.speed!, color: metaColor), + const SizedBox(width: 8), + TickDivider(color: palette.progressTrack), + const SizedBox(width: 8), + ], + if (task.uploading) ...[ + _TaskTransferSpeed(icon: Icons.north, value: task.uploadSpeed!, color: metaColor), + const SizedBox(width: 8), + TickDivider(color: palette.progressTrack), + const SizedBox(width: 8), + ], + Text(context.l10n.completed, style: TextStyle(color: palette.taskMetaSubtle, fontSize: 10, height: 1)), + ], + ), + ); + } +} + +class _TaskTransferSpeed extends StatelessWidget { + const _TaskTransferSpeed({required this.icon, required this.value, required this.color}); + + final IconData icon; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Text(value, style: TextStyle(color: color, fontSize: 10, height: 1)), + ], + ); + } +} + +class TickDivider extends StatelessWidget { + const TickDivider({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 4, + height: 4, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(999)), + ); + } +} + +class TaskCardActionButton extends StatelessWidget { + const TaskCardActionButton({ + super.key, + required this.icon, + required this.color, + required this.onPressed, + required this.hoverBackground, + this.buttonSize = 21, + this.iconSize = 14, + }); + + final IconData icon; + final Color color; + final VoidCallback onPressed; + final Color hoverBackground; + final double buttonSize; + final double iconSize; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: HoverActionButton( + hoverBackground: hoverBackground, + onPressed: onPressed, + child: SizedBox( + width: buttonSize, + height: buttonSize, + child: Center( + child: Icon(icon, size: iconSize, color: color), + ), + ), + ), + ); + } +} + +class HoverActionButton extends StatefulWidget { + const HoverActionButton({super.key, required this.child, required this.onPressed, required this.hoverBackground}); + + final Widget child; + final VoidCallback onPressed; + final Color hoverBackground; + + @override + State createState() => _HoverActionButtonState(); +} + +class _HoverActionButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + decoration: BoxDecoration( + color: _hovered ? widget.hoverBackground : Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: widget.child, + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart new file mode 100644 index 00000000..bf9fb2fb --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart @@ -0,0 +1,148 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../domain/task_record.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../util/util.dart'; + +class TaskContextActions { + const TaskContextActions({ + required this.selected, + required this.allSelected, + required this.listeningForUpdate, + required this.onShowDetails, + required this.onOpenFile, + required this.onOpenDirectory, + required this.onBrowseFiles, + required this.onToggleSelectAll, + required this.onToggleSelected, + required this.onPause, + required this.onResume, + required this.onDelete, + required this.onUpdateUrl, + required this.onToggleUpdateListener, + }); + + final bool selected; + final bool allSelected; + final bool listeningForUpdate; + final VoidCallback onShowDetails; + final VoidCallback onOpenFile; + final VoidCallback onOpenDirectory; + final VoidCallback onBrowseFiles; + final VoidCallback onToggleSelectAll; + final VoidCallback onToggleSelected; + final VoidCallback onPause; + final VoidCallback onResume; + final VoidCallback onDelete; + final VoidCallback onUpdateUrl; + final VoidCallback onToggleUpdateListener; +} + +class TaskContextMenu extends StatelessWidget { + const TaskContextMenu({super.key, required this.task, required this.actions, required this.child}); + + final TaskRecord task; + final TaskContextActions actions; + final Widget child; + + @override + Widget build(BuildContext context) { + return shad.ContextMenu( + items: [ + shad.MenuButton( + key: const ValueKey('task-context-select-all'), + leading: Icon(actions.allSelected ? Icons.deselect : Icons.select_all, size: 16), + onPressed: (_) => actions.onToggleSelectAll(), + child: Text(actions.allSelected ? context.l10n.clearSelection : context.l10n.selectAll), + ), + shad.MenuButton( + key: const ValueKey('task-context-select'), + leading: Icon(actions.selected ? Icons.check_box : Icons.check_box_outline_blank, size: 16), + onPressed: (_) => actions.onToggleSelected(), + child: Text(actions.selected ? context.l10n.deselectTask : context.l10n.selectTask), + ), + const shad.MenuDivider(), + shad.MenuButton( + key: const ValueKey('task-context-resume'), + leading: const Icon(Icons.play_arrow, size: 16), + enabled: task.status == TaskStatus.paused || task.status == TaskStatus.failed, + onPressed: (_) => actions.onResume(), + child: Text(context.l10n.continueAction), + ), + shad.MenuButton( + key: const ValueKey('task-context-pause'), + leading: const Icon(Icons.pause, size: 16), + enabled: task.status == TaskStatus.downloading, + onPressed: (_) => actions.onPause(), + child: Text(context.l10n.pause), + ), + shad.MenuButton( + key: const ValueKey('task-context-delete'), + leading: const Icon(Icons.delete_outline, size: 16), + onPressed: (_) => actions.onDelete(), + child: Text(context.l10n.delete), + ), + const shad.MenuDivider(), + shad.MenuButton( + key: const ValueKey('task-context-update-url'), + leading: const Icon(Icons.link, size: 16), + enabled: task.canUpdateUrl, + subMenu: [ + shad.MenuButton( + leading: const Icon(Icons.edit_note, size: 16), + onPressed: (_) => actions.onUpdateUrl(), + child: Text(context.l10n.updateManually), + ), + shad.MenuButton( + leading: Icon(actions.listeningForUpdate ? Icons.hearing_disabled : Icons.hearing, size: 16), + onPressed: (_) => actions.onToggleUpdateListener(), + child: Text(actions.listeningForUpdate ? context.l10n.stopListening : context.l10n.listenForUrl), + ), + ], + child: Text(context.l10n.updateUrl), + ), + const shad.MenuDivider(), + shad.MenuButton( + key: const ValueKey('task-context-details'), + leading: const Icon(Icons.info_outline, size: 16), + onPressed: (_) => actions.onShowDetails(), + child: Text(context.l10n.taskDetailsInfoTab), + ), + if (shouldShowTaskOpenFileAction(task)) + shad.MenuButton( + key: const ValueKey('task-context-open-file'), + leading: const Icon(Icons.open_in_new, size: 16), + onPressed: (_) => actions.onOpenFile(), + child: Text(context.l10n.openFile), + ), + if (shouldShowTaskOpenDirectoryAction()) + shad.MenuButton( + key: const ValueKey('task-context-open-directory'), + leading: const Icon(Icons.folder_open_outlined, size: 16), + onPressed: (_) => actions.onOpenDirectory(), + child: Text(context.l10n.openDirectory), + ), + shad.MenuButton( + key: const ValueKey('task-context-browse-files'), + leading: const Icon(Icons.snippet_folder_outlined, size: 16), + onPressed: (_) => actions.onBrowseFiles(), + child: Text(context.l10n.browseFiles), + ), + ], + child: child, + ); + } +} + +@visibleForTesting +bool shouldShowTaskOpenFileAction(TaskRecord task, {bool? web}) { + return !(web ?? kIsWeb) && !task.isFolder && task.status == TaskStatus.completed; +} + +@visibleForTesting +bool shouldShowTaskOpenDirectoryAction({bool? web, bool? desktop}) { + return !(web ?? kIsWeb) && (desktop ?? Util.isDesktop()); +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart new file mode 100644 index 00000000..f78cabda --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart @@ -0,0 +1,78 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; + +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../l10n/l10n.dart'; + +Future showTaskDeleteDialog(BuildContext context, {required int taskCount, required bool keepFiles}) async { + var currentKeepFiles = keepFiles; + final dialog = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + final contentWidth = (MediaQuery.sizeOf(dialogContext).width - 128).clamp(180.0, 380.0); + return AlertDialog( + leading: Container( + key: const ValueKey('task-delete-dialog-icon'), + width: 40, + height: 40, + decoration: BoxDecoration( + color: palette.error.withValues(alpha: 0.08), + border: Border.all(color: palette.error.withValues(alpha: 0.16)), + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Icon(Icons.delete_outline, size: 20, color: palette.error), + ), + title: Text(dialogContext.l10n.deleteTasksTitle(taskCount)), + content: SizedBox( + width: contentWidth, + child: AnimatedContainer( + key: const ValueKey('task-delete-keep-files-option'), + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric( + horizontal: AppDesignTokens.space16, + vertical: AppDesignTokens.space12, + ), + decoration: BoxDecoration( + color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft, + border: Border.all(color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + dialogContext.l10n.deleteTaskTip, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + const SizedBox(width: AppDesignTokens.space16), + Switch( + key: const ValueKey('task-delete-keep-files-switch'), + value: currentKeepFiles, + onChanged: (value) => setDialogState(() => currentKeepFiles = value), + ), + ], + ), + ), + ), + actions: [ + SecondaryButton(onPressed: () => closeOverlay(dialogContext), child: Text(dialogContext.l10n.cancel)), + DestructiveButton( + onPressed: () => closeOverlay(dialogContext, currentKeepFiles), + child: Text(dialogContext.l10n.delete), + ), + ], + ); + }, + ); + }, + ); + return dialog.future; +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart new file mode 100644 index 00000000..7ace3d50 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -0,0 +1,625 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../../../shared/widgets/detail/app_detail_surface.dart'; +import '../../../../core/utils/text_wrap.dart'; +import '../../../../l10n/l10n.dart'; +import '../../domain/task_record.dart'; +import '../task_record_localizations.dart'; +import '../../application/task_runtime_status_provider.dart'; +import 'task_file_browser_dialog.dart'; +import 'task_file_tree.dart'; +import 'task_statistics/task_statistics_tab.dart'; + +class TaskDrawer extends ConsumerStatefulWidget { + const TaskDrawer({ + super.key, + required this.task, + required this.onClose, + required this.onOpenStorage, + required this.onUpdateUrl, + }); + + final TaskRecord? task; + final VoidCallback onClose; + final VoidCallback onOpenStorage; + final Future Function(String url) onUpdateUrl; + + @override + ConsumerState createState() => _TaskDrawerState(); +} + +class _TaskDrawerState extends ConsumerState { + @override + Widget build(BuildContext context) { + final isOpen = widget.task != null; + final task = widget.task; + return AppDetailDrawer( + open: isOpen, + title: context.l10n.taskDetails, + onClose: widget.onClose, + drawerKey: const ValueKey('task-details-drawer'), + child: task == null + ? const SizedBox.shrink() + : TaskDetailsView( + task: task, + mobile: false, + onOpenStorage: widget.onOpenStorage, + onUpdateUrl: widget.onUpdateUrl, + ), + ); + } +} + +class TaskDetailsView extends ConsumerStatefulWidget { + const TaskDetailsView({ + super.key, + required this.task, + required this.mobile, + required this.onOpenStorage, + required this.onUpdateUrl, + }); + + final TaskRecord task; + final bool mobile; + final VoidCallback onOpenStorage; + final Future Function(String url) onUpdateUrl; + + @override + ConsumerState createState() => _TaskDetailsViewState(); +} + +class _TaskDetailsViewState extends ConsumerState { + final _urlController = TextEditingController(); + int _tabIndex = 0; + bool _copied = false; + bool _storagePathCopied = false; + bool _editingUrl = false; + bool _updatingUrl = false; + + @override + void initState() { + super.initState(); + _urlController.text = widget.task.url; + } + + @override + void didUpdateWidget(covariant TaskDetailsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.task.id != oldWidget.task.id) { + _tabIndex = 0; + _copied = false; + _storagePathCopied = false; + _editingUrl = false; + _updatingUrl = false; + _urlController.text = widget.task.url; + } else if (!_editingUrl && widget.task.url != oldWidget.task.url) { + _urlController.text = widget.task.url; + } + if (!widget.task.canUpdateUrl && _editingUrl) { + _editingUrl = false; + _updatingUrl = false; + _urlController.text = widget.task.url; + } + } + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final contentPadding = widget.mobile + ? AppDesignTokens.taskDetailsMobilePadding + : AppDesignTokens.taskDetailsDesktopPadding; + final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null; + return Column( + children: [ + Container( + height: 48, + padding: EdgeInsets.symmetric(horizontal: contentPadding), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _DrawerTabButton( + label: context.l10n.taskDetailsInfoTab, + active: _tabIndex == 0, + onTap: () => setState(() => _tabIndex = 0), + ), + const SizedBox(width: 24), + _DrawerTabButton( + label: context.l10n.taskDetailsFilesTab, + active: _tabIndex == 1, + onTap: () => setState(() => _tabIndex = 1), + ), + const SizedBox(width: 24), + _DrawerTabButton( + label: context.l10n.taskDetailsStatsTab, + active: _tabIndex == 2, + onTap: () => setState(() => _tabIndex = 2), + ), + ], + ), + ), + ), + ], + ), + ), + Expanded( + child: switch (_tabIndex) { + 0 => SingleChildScrollView( + key: PageStorageKey('task-info-${widget.task.id}'), + padding: EdgeInsets.all(contentPadding), + child: _TaskInfoTab( + task: widget.task, + copied: _copied, + storagePathCopied: _storagePathCopied, + canOpenStorage: _canOpenStoragePath, + editingUrl: _editingUrl, + updatingUrl: _updatingUrl, + urlController: _urlController, + onOpenStorage: widget.onOpenStorage, + onEditUrl: widget.task.canUpdateUrl + ? () => setState(() { + _urlController.text = widget.task.url; + _editingUrl = true; + }) + : null, + onCancelEditUrl: () => setState(() { + _urlController.text = widget.task.url; + _editingUrl = false; + }), + onSaveUrl: () => unawaited(_saveUrl()), + onCopy: () async { + await Clipboard.setData(ClipboardData(text: widget.task.url)); + if (mounted) setState(() => _copied = true); + }, + onCopyStorage: () async { + await Clipboard.setData(ClipboardData(text: widget.task.storagePath)); + if (mounted) setState(() => _storagePathCopied = true); + }, + ), + ), + 1 => Padding( + padding: EdgeInsets.all(contentPadding), + child: TaskFileTree( + task: widget.task, + runtimeStatus: runtimeStatus, + progressAction: _TaskDetailIconButton( + key: const ValueKey('task-files-browse'), + label: context.l10n.browseFiles, + icon: const Icon(Icons.snippet_folder_outlined, size: 18), + onPressed: _browseFiles, + ), + ), + ), + _ => TaskStatisticsTab(task: widget.task, mobile: widget.mobile), + }, + ), + ], + ); + } + + bool get _canOpenStoragePath { + if (kIsWeb) return false; + return switch (defaultTargetPlatform) { + TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true, + _ => false, + }; + } + + void _browseFiles() { + unawaited(browseTaskFiles(context, widget.task, mobile: widget.mobile)); + } + + Future _saveUrl() async { + final value = _urlController.text.trim(); + if (value.isEmpty || _updatingUrl || !widget.task.canUpdateUrl) return; + setState(() => _updatingUrl = true); + try { + await widget.onUpdateUrl(value); + if (!mounted) return; + setState(() { + _editingUrl = false; + _updatingUrl = false; + }); + } catch (_) { + if (mounted) setState(() => _updatingUrl = false); + } + } +} + +class _DrawerTabButton extends StatelessWidget { + const _DrawerTabButton({required this.label, required this.active, required this.onTap}); + + final String label; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: active ? palette.textPrimary : Colors.transparent, width: 2)), + ), + child: Text( + label, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + +class _TaskInfoTab extends StatelessWidget { + const _TaskInfoTab({ + required this.task, + required this.copied, + required this.storagePathCopied, + required this.canOpenStorage, + required this.editingUrl, + required this.updatingUrl, + required this.urlController, + required this.onCopy, + required this.onCopyStorage, + required this.onOpenStorage, + required this.onEditUrl, + required this.onCancelEditUrl, + required this.onSaveUrl, + }); + + final TaskRecord task; + final bool copied; + final bool storagePathCopied; + final bool canOpenStorage; + final bool editingUrl; + final bool updatingUrl; + final TextEditingController urlController; + final VoidCallback onCopy; + final VoidCallback onCopyStorage; + final VoidCallback onOpenStorage; + final VoidCallback? onEditUrl; + final VoidCallback onCancelEditUrl; + final VoidCallback onSaveUrl; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoBlock(label: context.l10n.name, value: task.name), + _InfoBlock( + label: context.l10n.status, + value: task.localizedStatus(context.l10n), + error: task.status == TaskStatus.failed, + ), + _InfoBlock( + label: context.l10n.size, + value: task.status == TaskStatus.completed + ? task.total ?? task.downloaded + : task.total != null + ? '${task.downloaded} / ${task.total}' + : task.downloaded, + ), + if (task.speed != null) _InfoBlock(label: context.l10n.speed, value: task.speed!), + if (task.uploading) _InfoBlock(label: context.l10n.uploadSpeed, value: task.uploadSpeed!), + if (task.status == TaskStatus.completed) + _InfoBlock(label: context.l10n.downloadDuration, value: task.localizedDownloadDuration(context.l10n) ?? '—') + else if (task.status == TaskStatus.paused) + _InfoBlock(label: context.l10n.remaining, value: '—') + else if (task.localizedRemaining(context.l10n) case final remaining?) + _InfoBlock(label: context.l10n.remaining, value: remaining), + if (task.createdAt case final createdAt?) + _InfoBlock(label: context.l10n.createdAt, value: _formatTaskDateTime(createdAt)), + Container( + key: const ValueKey('task-details-info-divider'), + height: 1, + margin: const EdgeInsets.symmetric(vertical: 4), + color: palette.border, + ), + if (editingUrl) + _EditableUrlBlock( + controller: urlController, + updating: updatingUrl, + onCancel: onCancelEditUrl, + onSave: onSaveUrl, + ) + else + _CopyableInfoBlock( + label: context.l10n.downloadLink, + value: task.url, + valueKey: const ValueKey('task-details-url-value'), + actionKey: const ValueKey('task-details-copy-url'), + actionLabel: copied ? context.l10n.copied : context.l10n.copy, + actionIcon: copied ? Icons.check : Icons.copy_outlined, + secondaryActionLabel: onEditUrl == null ? null : context.l10n.edit, + secondaryActionIcon: Icons.edit_outlined, + onSecondaryTap: onEditUrl, + onTap: onCopy, + ), + _CopyableInfoBlock( + label: context.l10n.storagePath, + value: task.storagePath, + valueKey: const ValueKey('task-details-storage-value'), + actionKey: ValueKey(canOpenStorage ? 'task-details-open-storage' : 'task-details-copy-storage'), + actionLabel: canOpenStorage + ? context.l10n.open + : storagePathCopied + ? context.l10n.copied + : context.l10n.copy, + actionIcon: canOpenStorage + ? Icons.folder_open_outlined + : storagePathCopied + ? Icons.check + : Icons.copy_outlined, + onTap: canOpenStorage ? onOpenStorage : onCopyStorage, + ), + ], + ); + } +} + +String _formatTaskDateTime(DateTime value) { + final local = value.toLocal(); + String two(int number) => number.toString().padLeft(2, '0'); + return '${local.year}-${two(local.month)}-${two(local.day)} ' + '${two(local.hour)}:${two(local.minute)}:${two(local.second)}'; +} + +class _InfoBlock extends StatelessWidget { + const _InfoBlock({required this.label, required this.value, this.error = false}); + + final String label; + final String value; + final bool error; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLabel(label), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + style: TextStyle(color: error ? palette.error : palette.textPrimary, fontSize: 12, height: 1.35), + ), + ), + ], + ), + ); + } +} + +class _CopyableInfoBlock extends StatelessWidget { + const _CopyableInfoBlock({ + required this.label, + required this.value, + required this.valueKey, + required this.actionKey, + required this.actionLabel, + required this.actionIcon, + required this.onTap, + this.secondaryActionLabel, + this.secondaryActionIcon, + this.onSecondaryTap, + }); + + final String label; + final String value; + final Key valueKey; + final Key actionKey; + final String actionLabel; + final IconData actionIcon; + final VoidCallback onTap; + final String? secondaryActionLabel; + final IconData? secondaryActionIcon; + final VoidCallback? onSecondaryTap; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLabel(label), + const SizedBox(width: 12), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + softWrapAnywhere(value), + key: valueKey, + semanticsLabel: value, + softWrap: true, + style: TextStyle(color: palette.textSecondary, fontSize: 11, height: 1.35), + ), + ), + const SizedBox(width: 8), + if (secondaryActionLabel != null && secondaryActionIcon != null && onSecondaryTap != null) ...[ + _TaskDetailIconButton( + key: const ValueKey('task-details-edit-url'), + label: secondaryActionLabel!, + icon: Icon(secondaryActionIcon!, size: 16), + onPressed: onSecondaryTap, + ), + const SizedBox(width: 4), + ], + _TaskDetailIconButton( + key: actionKey, + label: actionLabel, + icon: Icon(actionIcon, size: 16), + onPressed: onTap, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _InfoLabel extends StatelessWidget { + const _InfoLabel(this.label); + + final String label; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return SizedBox( + width: 88, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + label, + style: TextStyle( + color: palette.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + height: 1.3, + ), + ), + ), + ); + } +} + +class _EditableUrlBlock extends StatelessWidget { + const _EditableUrlBlock({ + required this.controller, + required this.updating, + required this.onCancel, + required this.onSave, + }); + + final TextEditingController controller; + final bool updating; + final VoidCallback onCancel; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLabel(context.l10n.downloadLink), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 76, + child: shad.TextField( + controller: controller, + minLines: null, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(color: palette.textPrimary, fontSize: 11, height: 1.3), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: palette.inputBg, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: palette.border), + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _TaskDetailIconButton( + key: const ValueKey('task-details-cancel-url'), + label: context.l10n.cancel, + icon: const Icon(Icons.close, size: 17), + onPressed: updating ? null : onCancel, + ), + const SizedBox(width: 8), + _TaskDetailIconButton( + key: const ValueKey('task-details-save-url'), + label: context.l10n.updateAndResume, + icon: updating + ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) + : const Icon(Icons.check, size: 17), + onPressed: updating ? null : onSave, + primary: true, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _TaskDetailIconButton extends StatelessWidget { + const _TaskDetailIconButton({ + super.key, + required this.label, + required this.icon, + required this.onPressed, + this.primary = false, + }); + + final String label; + final Widget icon; + final VoidCallback? onPressed; + final bool primary; + + @override + Widget build(BuildContext context) { + final button = primary + ? shad.IconButton.primary(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: icon) + : shad.IconButton.ghost(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: icon); + return AppTooltip( + message: label, + child: SizedBox.square(dimension: 28, child: button), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart new file mode 100644 index 00000000..73b7f816 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart @@ -0,0 +1,103 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../shared/theme/app_palette.dart'; +import '../../../../l10n/l10n.dart'; + +class TaskEmptyState extends StatelessWidget { + const TaskEmptyState({super.key, this.message}); + + final String? message; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CustomPaint( + key: const ValueKey('task-empty-illustration'), + size: const Size(148, 108), + painter: _TaskEmptyIllustrationPainter(palette: palette), + ), + const SizedBox(height: 18), + Text( + message ?? context.l10n.emptyTaskList, + style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ), + ); + } +} + +class _TaskEmptyIllustrationPainter extends CustomPainter { + const _TaskEmptyIllustrationPainter({required this.palette}); + + final AppPalette palette; + + @override + void paint(Canvas canvas, Size size) { + final borderPaint = Paint() + ..color = palette.border + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4; + final sheetRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 34, 6, 68, 72), const Radius.circular(7)); + + canvas.drawRRect(sheetRect.shift(const Offset(0, 3)), Paint()..color = palette.textMuted.withValues(alpha: 0.06)); + canvas.drawRRect(sheetRect, Paint()..color = palette.cardBg); + canvas.drawRRect(sheetRect, borderPaint); + + final rowPaint = Paint() + ..color = palette.textMuted.withValues(alpha: 0.34) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + final centerX = size.width / 2; + for (var row = 0; row < 2; row++) { + final y = 20.0 + row * 14; + canvas.drawCircle(Offset(centerX - 22, y), 2.5, Paint()..color = palette.surfaceSoft); + canvas.drawLine(Offset(centerX - 14, y), Offset(centerX + 21, y), rowPaint); + } + + final emptyBadgePaint = Paint() + ..color = palette.surfaceSoft + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(centerX, 56), 11, emptyBadgePaint); + canvas.drawCircle(Offset(centerX, 56), 11, borderPaint); + canvas.drawLine(Offset(centerX - 4, 56), Offset(centerX + 4, 56), rowPaint); + + final trayPath = Path() + ..moveTo(26, 63) + ..lineTo(49, 63) + ..lineTo(57, 75) + ..quadraticBezierTo(59, 78, 63, 78) + ..lineTo(85, 78) + ..quadraticBezierTo(89, 78, 91, 75) + ..lineTo(99, 63) + ..lineTo(122, 63) + ..lineTo(115, 96) + ..quadraticBezierTo(114, 101, 108, 101) + ..lineTo(40, 101) + ..quadraticBezierTo(34, 101, 33, 96) + ..close(); + canvas.drawPath(trayPath, Paint()..color = palette.surfaceSoft); + final trayPaint = Paint() + ..color = palette.textSecondary.withValues(alpha: 0.72) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(trayPath, trayPaint); + } + + @override + bool shouldRepaint(_TaskEmptyIllustrationPainter oldDelegate) { + return oldDelegate.palette != palette; + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart new file mode 100644 index 00000000..ad695501 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../../../util/util.dart'; +import '../../domain/task_record.dart'; +import 'task_file_manager.dart'; + +Future browseTaskFiles(BuildContext context, TaskRecord task, {required bool mobile}) async { + if (mobile && !Util.isWeb()) { + await context.push('/tasks/${Uri.encodeComponent(task.id)}/files', extra: task); + return; + } + await showTaskFileBrowserDialog(context, task); +} + +Future showTaskFileBrowserDialog(BuildContext context, TaskRecord task) async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) { + final viewport = MediaQuery.sizeOf(dialogContext); + final dialogWidth = (viewport.width - 64).clamp(240.0, 820.0); + final dialogHeight = (viewport.height - 160).clamp(180.0, 620.0); + + return shad.AlertDialog( + key: const ValueKey('task-file-browser-dialog'), + padding: const EdgeInsets.all(20), + title: SizedBox( + width: dialogWidth, + child: Row( + children: [ + Expanded(child: Text(dialogContext.l10n.browseFiles, maxLines: 1, overflow: TextOverflow.ellipsis)), + AppTooltip( + message: dialogContext.l10n.close, + child: shad.IconButton.ghost( + size: shad.ButtonSize.xSmall, + onPressed: () => shad.closeOverlay(dialogContext), + icon: const Icon(Icons.close, size: 18), + ), + ), + ], + ), + ), + content: SizedBox( + width: dialogWidth, + height: dialogHeight, + child: TaskFileManagerView(task: task), + ), + ); + }, + ); + await overlay.future; +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart new file mode 100644 index 00000000..76847070 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart @@ -0,0 +1,369 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as path; +import 'package:share_plus/share_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../api/api.dart' as api; +import '../../../../core/utils/file_explorer.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; +import '../../../../util/browser_download/browser_download.dart'; +import '../../../../util/util.dart'; +import '../../domain/task_record.dart'; + +class TaskFileManagerView extends StatefulWidget { + const TaskFileManagerView({super.key, required this.task, this.webActions, this.desktopActions}); + + final TaskRecord task; + final bool? webActions; + final bool? desktopActions; + + @override + State createState() => _TaskFileManagerViewState(); +} + +class _TaskFileManagerViewState extends State { + late _TaskFileDirectoryIndex _index; + var _directory = '/'; + + bool get _webActions => widget.webActions ?? Util.isWeb(); + bool get _desktopActions => widget.desktopActions ?? Util.isDesktop(); + + @override + void initState() { + super.initState(); + _index = _TaskFileDirectoryIndex(widget.task); + } + + @override + void didUpdateWidget(covariant TaskFileManagerView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.task.id != oldWidget.task.id || !identical(widget.task.files, oldWidget.task.files)) { + _index = _TaskFileDirectoryIndex(widget.task); + if (!_index.contains(_directory)) _directory = '/'; + } + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final entries = _index.entries(_directory); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _FileBreadcrumb(directory: _directory, onSelected: (directory) => setState(() => _directory = directory)), + Expanded( + child: entries.isEmpty + ? Center( + child: Text(context.l10n.noFiles, style: TextStyle(color: palette.textMuted, fontSize: 13)), + ) + : ListView.builder( + key: PageStorageKey('task-file-manager-${widget.task.id}-$_directory'), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return _FileManagerRow( + key: ValueKey('managed-file-${entry.fullPath}'), + entry: entry, + childCount: entry.directory ? _index.entries(entry.fullPath).length : 0, + webActions: _webActions, + desktopActions: _desktopActions, + onOpenDirectory: () => setState(() => _directory = entry.fullPath), + onOpenSystemDirectory: () => unawaited(_revealEntry(entry)), + onOpenFile: () => unawaited(_openFile(entry)), + onShareFile: () => unawaited(_shareFile(context, entry)), + onDownloadFile: () => _downloadFile(entry), + ); + }, + ), + ), + ], + ); + } + + Future _openFile(_TaskFileEntry entry) async { + final opened = _webActions + ? await launchUrl(Uri.parse(_accessUrl(entry)), webOnlyWindowName: '_blank') + : await FileExplorer.open(_localPath(entry)); + if (!opened && mounted) { + showAppToast(context, context.l10n.unableOpenPath(entry.name), type: AppToastType.error); + } + } + + Future _revealEntry(_TaskFileEntry entry) async { + final entryPath = _localPath(entry); + if (!await FileExplorer.reveal(entryPath) && mounted) { + showAppToast(context, context.l10n.unableLocatePath(entryPath), type: AppToastType.error); + } + } + + Future _shareFile(BuildContext actionContext, _TaskFileEntry entry) async { + try { + final renderBox = actionContext.findRenderObject(); + final origin = renderBox is RenderBox ? renderBox.localToGlobal(Offset.zero) & renderBox.size : null; + await Share.shareXFiles([XFile(_localPath(entry))], sharePositionOrigin: origin); + } catch (error) { + if (mounted) showAppToast(context, error.toString(), type: AppToastType.error); + } + } + + void _downloadFile(_TaskFileEntry entry) { + download(_accessUrl(entry), entry.name); + } + + String _localPath(_TaskFileEntry entry) { + if (!widget.task.isFolder && _index.fileCount == 1) return widget.task.storagePath; + final segments = entry.fullPath.split('/').where((part) => part.isNotEmpty); + return path.joinAll([widget.task.storagePath, ...segments]); + } + + String _accessUrl(_TaskFileEntry entry) { + final encodedPath = entry.fullPath.split('/').where((part) => part.isNotEmpty).map(Uri.encodeComponent).join('/'); + return api.join('/fs/tasks/${Uri.encodeComponent(widget.task.id)}/$encodedPath'); + } +} + +class _FileBreadcrumb extends StatelessWidget { + const _FileBreadcrumb({required this.directory, required this.onSelected}); + + final String directory; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final segments = directory.split('/').where((part) => part.isNotEmpty).toList(growable: false); + final crumbs = <({String label, String path})>[(label: '/', path: '/')]; + var current = ''; + for (final segment in segments) { + current = '$current/$segment'; + crumbs.add((label: segment, path: current)); + } + + return SizedBox( + height: 48, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + for (var index = 0; index < crumbs.length; index++) ...[ + if (index > 0) Icon(Icons.chevron_right, size: 16, color: palette.textMuted), + GestureDetector( + key: ValueKey('file-breadcrumb-${crumbs[index].path}'), + onTap: () => onSelected(crumbs[index].path), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), + child: Text( + crumbs[index].label, + style: TextStyle( + color: index == crumbs.length - 1 ? palette.textPrimary : palette.textSecondary, + fontSize: 12, + fontWeight: index == crumbs.length - 1 ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _FileManagerRow extends StatelessWidget { + const _FileManagerRow({ + super.key, + required this.entry, + required this.childCount, + required this.webActions, + required this.desktopActions, + required this.onOpenDirectory, + required this.onOpenSystemDirectory, + required this.onOpenFile, + required this.onShareFile, + required this.onDownloadFile, + }); + + final _TaskFileEntry entry; + final int childCount; + final bool webActions; + final bool desktopActions; + final VoidCallback onOpenDirectory; + final VoidCallback onOpenSystemDirectory; + final VoidCallback onOpenFile; + final VoidCallback onShareFile; + final VoidCallback onDownloadFile; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GestureDetector( + onTap: entry.directory ? onOpenDirectory : null, + behavior: HitTestBehavior.opaque, + child: Container( + constraints: const BoxConstraints(minHeight: 54), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + Icon( + taskFileTypeIcon(entry.name, isFolder: entry.directory), + size: 20, + color: entry.directory ? palette.brand : palette.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 2), + Text( + entry.directory ? context.l10n.items(childCount) : Util.fmtByte(entry.size), + style: TextStyle(color: palette.textMuted, fontSize: 11), + ), + ], + ), + ), + if (entry.directory) ...[ + if (desktopActions) ...[ + _FileActionButton( + label: context.l10n.openDirectory, + icon: Icons.folder_open_outlined, + onPressed: onOpenSystemDirectory, + ), + const SizedBox(width: 4), + ], + Icon(Icons.chevron_right, size: 18, color: palette.textMuted), + ] else ...[ + _FileActionButton(label: context.l10n.openFile, icon: Icons.open_in_new, onPressed: onOpenFile), + if (desktopActions) ...[ + const SizedBox(width: 4), + _FileActionButton( + label: context.l10n.openDirectory, + icon: Icons.folder_open_outlined, + onPressed: onOpenSystemDirectory, + ), + ] else ...[ + const SizedBox(width: 4), + if (webActions) + _FileActionButton( + label: context.l10n.download, + icon: Icons.download_outlined, + onPressed: onDownloadFile, + ) + else + _FileActionButton(label: context.l10n.share, icon: Icons.share_outlined, onPressed: onShareFile), + ], + ], + ], + ), + ), + ); + } +} + +class _FileActionButton extends StatelessWidget { + const _FileActionButton({required this.label, required this.icon, required this.onPressed}); + + final String label; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return AppTooltip( + message: label, + child: shad.IconButton.ghost( + size: shad.ButtonSize.small, + onPressed: onPressed, + icon: Icon(icon, size: 17, color: palette.textSecondary), + ), + ); + } +} + +class _TaskFileDirectoryIndex { + _TaskFileDirectoryIndex(TaskRecord task) { + final sourceFiles = task.files.isEmpty + ? [TaskFileNode(path: '/', name: task.name, sizeBytes: task.totalBytes ?? 0)] + : task.files; + fileCount = sourceFiles.length; + final singleFile = !task.isFolder && fileCount == 1; + for (final file in sourceFiles) { + final directory = _normalizeDirectory(file.path); + final name = singleFile ? path.basename(task.storagePath) : file.name; + final fullPath = directory == '/' ? '/$name' : '$directory/$name'; + _add(directory, _TaskFileEntry(directory: false, fullPath: fullPath, name: name, size: file.sizeBytes)); + _addParentDirectories(directory); + } + for (final entries in _entries.values) { + entries.sort((left, right) { + if (left.directory != right.directory) return left.directory ? -1 : 1; + return left.name.toLowerCase().compareTo(right.name.toLowerCase()); + }); + } + } + + final Map> _entries = {'/': []}; + late final int fileCount; + + bool contains(String directory) => _entries.containsKey(directory); + + List<_TaskFileEntry> entries(String directory) => _entries[directory] ?? const []; + + void _addParentDirectories(String directory) { + var current = directory; + while (current != '/') { + final parent = path.posix.dirname(current); + final normalizedParent = parent == '.' ? '/' : parent; + final name = path.posix.basename(current); + _add(normalizedParent, _TaskFileEntry(directory: true, fullPath: current, name: name, size: 0)); + _entries.putIfAbsent(current, () => []); + current = normalizedParent; + } + } + + void _add(String directory, _TaskFileEntry entry) { + final entries = _entries.putIfAbsent(directory, () => []); + if (!entries.any((item) => item.fullPath == entry.fullPath)) entries.add(entry); + } +} + +class _TaskFileEntry { + const _TaskFileEntry({required this.directory, required this.fullPath, required this.name, required this.size}); + + final bool directory; + final String fullPath; + final String name; + final int size; +} + +String _normalizeDirectory(String value) { + var normalized = value.trim().replaceAll('\\', '/'); + if (normalized.isEmpty || normalized == '.') return '/'; + if (!normalized.startsWith('/')) normalized = '/$normalized'; + while (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + return normalized; +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart new file mode 100644 index 00000000..88745fc6 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../api/model/task.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/file_tree_view.dart'; +import '../../../../util/util.dart'; +import '../../../../l10n/l10n.dart'; +import '../../domain/task_record.dart'; +import 'task_progress_bar.dart'; + +class TaskFileTree extends StatefulWidget { + const TaskFileTree({super.key, required this.task, this.runtimeStatus, this.progressAction}); + + final TaskRecord task; + final TaskRuntimeStatus? runtimeStatus; + final Widget? progressAction; + + @override + State createState() => _TaskFileTreeState(); +} + +class _TaskFileTreeState extends State { + late List> _items; + + @override + void initState() { + super.initState(); + _items = _buildItems(widget.task.files); + } + + @override + void didUpdateWidget(covariant TaskFileTree oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.task.id != oldWidget.task.id || !identical(widget.task.files, oldWidget.task.files)) { + _items = _buildItems(widget.task.files); + } + } + + @override + Widget build(BuildContext context) { + final progressByIndex = { + for (final file in widget.runtimeStatus?.files ?? const []) file.index: file, + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TaskDownloadProgress(task: widget.task, runtimeStatus: widget.runtimeStatus, action: widget.progressAction), + const SizedBox(height: 12), + Expanded( + child: FileTreeView( + items: _items, + keyPrefix: 'task-file-tree', + rowHeight: 27, + iconSize: 15, + iconBuilder: (node) => taskFileTypeIcon(node.label, isFolder: node.isFolder), + contentTextStyle: const TextStyle(fontSize: 11, height: 1.1), + trailingHeader: const _TaskFileColumnsHeader(), + trailingBuilder: (context, node) => _FileDownloadProgress( + node: node, + progressByIndex: progressByIndex, + runtimeStatusAvailable: widget.runtimeStatus != null, + fallbackProgress: widget.task.progress, + taskStatus: widget.task.status, + ), + ), + ), + ], + ); + } + + List> _buildItems(List files) { + return files + .asMap() + .entries + .map( + (entry) => FileTreeItem( + key: entry.key.toString(), + path: entry.value.path, + name: entry.value.name, + size: entry.value.sizeBytes, + data: entry.value, + ), + ) + .toList(growable: false); + } +} + +class _TaskDownloadProgress extends StatelessWidget { + const _TaskDownloadProgress({required this.task, required this.runtimeStatus, this.action}); + + final TaskRecord task; + final TaskRuntimeStatus? runtimeStatus; + final Widget? action; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final runtime = runtimeStatus; + final totalBytes = runtime?.total ?? 0; + final downloadedBytes = runtime?.downloaded ?? 0; + final progress = runtime == null + ? task.progress + : totalBytes > 0 + ? (downloadedBytes / totalBytes).clamp(0.0, 1.0) + : null; + final progressLabel = progress == null ? '--' : '${(progress * 100).toStringAsFixed(progress < 0.1 ? 1 : 0)}%'; + final transferred = runtime == null + ? (task.total == null ? task.downloaded : '${task.downloaded} / ${task.total}') + : totalBytes > 0 + ? '${Util.fmtByte(downloadedBytes)} / ${Util.fmtByte(totalBytes)}' + : Util.fmtByte(downloadedBytes); + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + context.l10n.progress, + style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + Text(transferred, style: TextStyle(color: palette.textSecondary, fontSize: 11)), + const SizedBox(width: 8), + SizedBox( + width: 38, + child: Text( + progressLabel, + textAlign: TextAlign.right, + style: TextStyle(color: palette.textPrimary, fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 7), + TaskProgressBar( + key: const ValueKey('task-files-total-progress'), + value: progress, + indeterminate: runtime == null && task.isIndeterminate, + shimmer: task.status == TaskStatus.downloading, + height: 4, + trackColor: palette.progressTrack, + fillColor: palette.brandProgress, + highlightStartColor: palette.brandProgress.withValues(alpha: 0), + highlightEndColor: palette.brandProgress.withValues(alpha: 0), + ), + ], + ), + ), + if (action != null) ...[const SizedBox(width: 12), action!], + ], + ); + } +} + +class _FileDownloadProgress extends StatelessWidget { + const _FileDownloadProgress({ + required this.node, + required this.progressByIndex, + required this.runtimeStatusAvailable, + required this.fallbackProgress, + required this.taskStatus, + }); + + final FileTreeNode node; + final Map progressByIndex; + final bool runtimeStatusAvailable; + final double? fallbackProgress; + final TaskStatus taskStatus; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final taskStateIcon = switch (taskStatus) { + TaskStatus.paused => Semantics( + label: context.l10n.pause, + child: Icon(Icons.pause_circle_filled, size: 16, color: palette.textMuted), + ), + TaskStatus.failed => Semantics( + label: context.l10n.failed, + child: Icon(Icons.error_rounded, size: 16, color: palette.error), + ), + TaskStatus.completed => Semantics( + label: context.l10n.completed, + child: Icon(shad.BootstrapIcons.checkCircleFill, size: 16, color: palette.brandProgress), + ), + TaskStatus.downloading => null, + }; + if (taskStateIcon != null) { + return SizedBox( + key: ValueKey('task-file-progress-${node.key}'), + width: 116, + child: _TaskFileColumns(progress: taskStateIcon, size: _formatFileSize(node.size)), + ); + } + + final selectedFiles = node.leafItems + .map((item) => int.tryParse(item.key)) + .whereType() + .map((index) => progressByIndex[index]) + .whereType() + .toList(growable: false); + if (selectedFiles.isEmpty) { + final progress = runtimeStatusAvailable ? null : fallbackProgress; + final showFallbackProgress = !runtimeStatusAvailable && progress != null; + return SizedBox( + key: showFallbackProgress ? ValueKey('task-file-progress-${node.key}') : null, + width: 116, + child: _TaskFileColumns( + progress: showFallbackProgress + ? Semantics( + label: '${(progress * 100).toStringAsFixed(0)}%', + child: shad.CircularProgressIndicator( + value: progress, + size: 16, + strokeWidth: 2, + color: palette.brandProgress, + backgroundColor: palette.progressTrack, + ), + ) + : const SizedBox.square(dimension: 16), + size: _formatFileSize(node.size), + ), + ); + } + + final downloaded = selectedFiles.fold(0, (total, file) => total + file.downloaded); + final sizeKnown = selectedFiles.every((file) => file.size > 0); + final size = sizeKnown ? selectedFiles.fold(0, (total, file) => total + file.size) : 0; + final progress = size > 0 ? (downloaded / size).clamp(0.0, 1.0) : null; + final completed = progress != null && progress >= 1; + final progressLabel = progress == null ? Util.fmtByte(downloaded) : '${(progress * 100).toStringAsFixed(0)}%'; + + return SizedBox( + key: ValueKey('task-file-progress-${node.key}'), + width: 116, + child: _TaskFileColumns( + progress: Semantics( + label: completed ? context.l10n.completed : progressLabel, + child: completed + ? Icon(shad.BootstrapIcons.checkCircleFill, size: 16, color: palette.brandProgress) + : shad.CircularProgressIndicator( + value: progress ?? (downloaded > 0 ? null : 0), + size: 16, + strokeWidth: 2, + color: palette.brandProgress, + backgroundColor: palette.progressTrack, + ), + ), + size: _formatFileSize(node.size), + ), + ); + } +} + +class _TaskFileColumnsHeader extends StatelessWidget { + const _TaskFileColumnsHeader(); + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final style = TextStyle(color: palette.textSecondary, fontSize: 10, fontWeight: FontWeight.w600); + return _TaskFileColumns( + progress: Text(context.l10n.progress, maxLines: 1, style: style), + size: context.l10n.size, + sizeStyle: style, + ); + } +} + +class _TaskFileColumns extends StatelessWidget { + const _TaskFileColumns({required this.progress, required this.size, this.sizeStyle}); + + final Widget progress; + final String size; + final TextStyle? sizeStyle; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + children: [ + SizedBox(width: 44, child: Center(child: progress)), + const SizedBox(width: 4), + Expanded( + child: Text( + size, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + textAlign: TextAlign.right, + style: sizeStyle ?? TextStyle(color: palette.textSecondary, fontSize: 10), + ), + ), + ], + ); + } +} + +String _formatFileSize(int size) => size > 0 ? Util.fmtByte(size) : '--'; diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart new file mode 100644 index 00000000..ac139965 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart @@ -0,0 +1,200 @@ +import 'package:flutter/widgets.dart'; + +class TaskProgressBar extends StatefulWidget { + const TaskProgressBar({ + super.key, + required this.trackColor, + required this.fillColor, + required this.highlightStartColor, + required this.highlightEndColor, + this.value, + this.indeterminate = false, + this.shimmer = true, + this.height = 2, + }); + + final Color trackColor; + final Color fillColor; + final Color highlightStartColor; + final Color highlightEndColor; + final double? value; + final bool indeterminate; + final bool shimmer; + final double height; + + @override + State createState() => _TaskProgressBarState(); +} + +class _TaskProgressBarState extends State with SingleTickerProviderStateMixin { + static const double _shimmerPixelsPerSecond = 140.0; + static const double _shimmerMinDurationMs = 900; + static const double _shimmerMaxDurationMs = 3200; + + late final AnimationController _controller; + + bool get _shouldAnimate => widget.indeterminate || widget.shimmer; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1600)); + _syncAnimation(); + } + + @override + void didUpdateWidget(covariant TaskProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.indeterminate != oldWidget.indeterminate || widget.shimmer != oldWidget.shimmer) { + _syncAnimation(); + } + } + + void _syncAnimation() { + if (_shouldAnimate) { + if (!_controller.isAnimating) _controller.repeat(); + } else { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: ColoredBox( + color: widget.trackColor, + child: LayoutBuilder( + builder: (context, constraints) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + if (widget.indeterminate) { + final segmentWidth = constraints.maxWidth * 0.4; + final left = lerpDouble(-segmentWidth, constraints.maxWidth, _controller.value); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: left, + width: segmentWidth, + top: 0, + bottom: 0, + child: _ProgressFill( + color: widget.fillColor, + shimmer: widget.shimmer, + highlightStartColor: widget.highlightStartColor, + highlightEndColor: widget.highlightEndColor, + bandLeft: lerpDouble(-segmentWidth, segmentWidth * 2, _controller.value), + bandWidth: segmentWidth * 0.5, + ), + ), + ], + ); + } + + final fillWidth = constraints.maxWidth * ((widget.value ?? 0).clamp(0, 1)); + final bandWidth = fillWidth == 0 ? 0.0 : (fillWidth * 0.35).clamp(16.0, 40.0); + final travelDistance = fillWidth + bandWidth * 2; + final durationMs = (travelDistance / _shimmerPixelsPerSecond * 1000) + .clamp(_shimmerMinDurationMs, _shimmerMaxDurationMs) + .round(); + + if (widget.shimmer && widget.value != null && fillWidth > 0) { + final duration = Duration(milliseconds: durationMs); + if (_controller.duration != duration) { + _controller.duration = duration; + } + } + + return Stack( + fit: StackFit.expand, + children: [ + Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: fillWidth, + child: _ProgressFill( + color: widget.fillColor, + shimmer: widget.shimmer, + highlightStartColor: widget.highlightStartColor, + highlightEndColor: widget.highlightEndColor, + bandLeft: lerpDouble(-bandWidth, fillWidth + bandWidth, _controller.value), + bandWidth: bandWidth, + ), + ), + ), + ], + ); + }, + ); + }, + ), + ), + ), + ); + } + + double lerpDouble(double min, double max, double t) => min + ((max - min) * t); +} + +class _ProgressFill extends StatelessWidget { + const _ProgressFill({ + required this.color, + required this.shimmer, + required this.highlightStartColor, + required this.highlightEndColor, + required this.bandLeft, + required this.bandWidth, + }); + + final Color color; + final bool shimmer; + final Color highlightStartColor; + final Color highlightEndColor; + final double bandLeft; + final double bandWidth; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(999), + boxShadow: shimmer ? [BoxShadow(color: const Color(0x33FFFFFF), blurRadius: 8)] : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: shimmer + ? Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: bandLeft, + width: bandWidth, + top: 0, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [highlightStartColor, const Color(0xFFFFFFFF), highlightEndColor], + ), + ), + ), + ), + ], + ) + : const SizedBox.expand(), + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/peer_table.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/peer_table.dart new file mode 100644 index 00000000..b832c949 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/peer_table.dart @@ -0,0 +1,236 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart' show Icons, Scrollbar, ScrollbarOrientation; +import 'package:flutter/widgets.dart'; + +import '../../../../../api/model/task_stats.dart'; +import '../../../../../core/utils/byte_size_formatter.dart'; +import '../../../../../shared/theme/app_palette.dart'; +import '../../../../../l10n/l10n.dart'; + +enum PeerTableProtocol { bt, ed2k } + +enum _PeerSortColumn { address, client, transport, downloadSpeed, uploadSpeed, pieces, completion, relevance, source } + +class PeerTable extends StatefulWidget { + const PeerTable({super.key, required this.protocol, required this.peers}); + + final PeerTableProtocol protocol; + final List peers; + + @override + State createState() => _PeerTableState(); +} + +class _PeerTableState extends State { + static const _headerHeight = 40.0; + static const _rowHeight = 38.0; + + double get _tableWidth => widget.protocol == PeerTableProtocol.bt ? 1070 : 870; + + final ScrollController _horizontalController = ScrollController(); + final ScrollController _verticalController = ScrollController(); + _PeerSortColumn _sortColumn = _PeerSortColumn.downloadSpeed; + bool _ascending = false; + + @override + void dispose() { + _horizontalController.dispose(); + _verticalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final peers = [...widget.peers]..sort(_comparePeers); + return SizedBox( + height: 300, + child: LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(_tableWidth, constraints.maxWidth); + return Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + thickness: 7, + scrollbarOrientation: ScrollbarOrientation.bottom, + child: SingleChildScrollView( + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: contentWidth, + height: constraints.maxHeight, + child: Column( + children: [ + Container( + height: _headerHeight, + decoration: BoxDecoration( + color: palette.sideBg, + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row(children: _headers(context)), + ), + Expanded( + child: ListView.builder( + controller: _verticalController, + padding: const EdgeInsets.only(bottom: 12), + itemCount: peers.length, + itemExtent: _rowHeight, + itemBuilder: (context, index) => _PeerRow(protocol: widget.protocol, peer: peers[index]), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + List _headers(BuildContext context) => [ + _header(context.l10n.address, _PeerSortColumn.address, 180), + _header(context.l10n.client, _PeerSortColumn.client, 150), + _header(context.l10n.protocol, _PeerSortColumn.transport, 80), + _header(context.l10n.speed, _PeerSortColumn.downloadSpeed, 120), + _header(context.l10n.uploadSpeed, _PeerSortColumn.uploadSpeed, 120), + _header(context.l10n.pieces, _PeerSortColumn.pieces, 100), + if (widget.protocol == PeerTableProtocol.bt) ...[ + _header(context.l10n.progress, _PeerSortColumn.completion, 100), + _header(context.l10n.fileRelevance, _PeerSortColumn.relevance, 100), + ], + _header(context.l10n.source, _PeerSortColumn.source, 120), + ]; + + Widget _header(String label, _PeerSortColumn column, double width) { + final palette = AppPalette.of(context); + final active = _sortColumn == column; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + if (_sortColumn == column) { + _ascending = !_ascending; + } else { + _sortColumn = column; + _ascending = column != _PeerSortColumn.downloadSpeed && column != _PeerSortColumn.uploadSpeed; + } + }); + }, + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + if (active) ...[ + const SizedBox(width: 3), + Icon(_ascending ? Icons.arrow_upward : Icons.arrow_downward, size: 12, color: palette.textMuted), + ], + ], + ), + ), + ), + ); + } + + int _comparePeers(TaskPeerStats a, TaskPeerStats b) { + final result = switch (_sortColumn) { + _PeerSortColumn.address => a.address.compareTo(b.address), + _PeerSortColumn.client => a.client.compareTo(b.client), + _PeerSortColumn.transport => a.transport.compareTo(b.transport), + _PeerSortColumn.downloadSpeed => a.downloadSpeed.compareTo(b.downloadSpeed), + _PeerSortColumn.uploadSpeed => a.uploadSpeed.compareTo(b.uploadSpeed), + _PeerSortColumn.pieces => a.pieceCount.compareTo(b.pieceCount), + _PeerSortColumn.completion => (a.completion ?? -1).compareTo(b.completion ?? -1), + _PeerSortColumn.relevance => (a.relevance ?? -1).compareTo(b.relevance ?? -1), + _PeerSortColumn.source => a.source.compareTo(b.source), + }; + return _ascending ? result : -result; + } +} + +class _PeerRow extends StatelessWidget { + const _PeerRow({required this.protocol, required this.peer}); + + final PeerTableProtocol protocol; + final TaskPeerStats peer; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + _cell(context, peer.address, 180), + _cell(context, peer.client, 150), + _cell(context, _formatProtocol(protocol, peer.transport), 80), + _cell(context, _formatRate(peer.downloadSpeed), 120), + _cell(context, _formatRate(peer.uploadSpeed), 120), + _cell(context, peer.pieceCount.toString(), 100), + if (protocol == PeerTableProtocol.bt) ...[ + _cell(context, _formatPercentage(peer.completion), 100), + _cell(context, _formatPercentage(peer.relevance), 100), + ], + _cell(context, peer.source, 120), + ], + ), + ); + } + + Widget _cell(BuildContext context, String value, double width) { + final palette = AppPalette.of(context); + return SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: 11), + ), + ), + ); + } +} + +String _formatRate(int bytesPerSecond) => bytesPerSecond <= 0 ? '—' : '${_formatBytes(bytesPerSecond)}/s'; + +String _formatProtocol(PeerTableProtocol protocol, String transport) { + if (protocol == PeerTableProtocol.ed2k) return 'ED2K'; + return switch (transport.toLowerCase()) { + 'utp' => 'uTP', + 'webrtc' => 'WebRTC', + _ => 'BT', + }; +} + +String _formatPercentage(double? ratio) { + if (ratio == null) return '—'; + final percentage = ratio.clamp(0.0, 1.0) * 100; + final fractionDigits = percentage == percentage.roundToDouble() ? 0 : 1; + return '${percentage.toStringAsFixed(fractionDigits)}%'; +} + +String _formatBytes(int bytes) { + return ByteSizeFormatter.format(bytes); +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/piece_map.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/piece_map.dart new file mode 100644 index 00000000..37945169 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/piece_map.dart @@ -0,0 +1,204 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../../api/model/task_stats.dart'; +import '../../../../../core/utils/byte_size_formatter.dart'; +import '../../../../../shared/theme/app_palette.dart'; +import '../../../../../l10n/l10n.dart'; + +class PieceMap extends StatefulWidget { + const PieceMap({super.key, required this.pieceMap, this.totalBytes}); + + final TaskPieceMap pieceMap; + final int? totalBytes; + + @override + State createState() => _PieceMapState(); +} + +class _PieceMapState extends State { + static const _gap = 3.0; + static const _targetCellSize = 11.0; + // The default desktop drawer fits 28 columns, so 252 cells fill 9 rows. + static const _maxPaintedCells = 252; + + int? _focusedCell; + PointerDeviceKind? _lastPointerKind; + + @override + void didUpdateWidget(covariant PieceMap oldWidget) { + super.didUpdateWidget(oldWidget); + if (_focusedCell != null && _focusedCell! >= math.min(widget.pieceMap.pieceCount, _maxPaintedCells)) { + _focusedCell = null; + } + } + + List _displayPieces(int displayCount) { + final pieceMap = widget.pieceMap; + if (pieceMap.pieceCount <= displayCount) { + return List.generate(pieceMap.pieceCount, pieceMap.stateAt, growable: false); + } + return List.generate(displayCount, (cell) { + final start = cell * pieceMap.pieceCount ~/ displayCount; + final end = math.max(start + 1, (cell + 1) * pieceMap.pieceCount ~/ displayCount); + var allCompleted = true; + for (var index = start; index < math.min(end, pieceMap.pieceCount); index++) { + final state = pieceMap.stateAt(index); + if (state != TaskPieceState.completed) allCompleted = false; + } + if (allCompleted) return TaskPieceState.completed; + return TaskPieceState.pending; + }); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return LayoutBuilder( + builder: (context, constraints) { + if (widget.pieceMap.isEmpty) return const SizedBox.shrink(); + final width = constraints.maxWidth; + final columns = math.max(1, ((width + _gap) / (_targetCellSize + _gap)).floor()); + final displayCount = math.min(widget.pieceMap.pieceCount, _maxPaintedCells); + final pieces = _displayPieces(displayCount); + final focusedCell = _focusedCell != null && _focusedCell! < pieces.length ? _focusedCell : null; + final cellSize = (width - _gap * (columns - 1)) / columns; + final rows = (pieces.length / columns).ceil(); + final mapHeight = rows * cellSize + math.max(0, rows - 1) * _gap; + + int? cellAt(Offset offset) { + final column = (offset.dx / (cellSize + _gap)).floor(); + final row = (offset.dy / (cellSize + _gap)).floor(); + if (column < 0 || column >= columns || row < 0 || row >= rows) return null; + final localX = offset.dx - column * (cellSize + _gap); + final localY = offset.dy - row * (cellSize + _gap); + if (localX > cellSize || localY > cellSize) return null; + final index = row * columns + column; + return index < pieces.length ? index : null; + } + + void updateFocus(PointerEvent event, {required bool pressed}) { + final index = cellAt(event.localPosition); + if (pressed || event.kind == PointerDeviceKind.mouse || event.kind == PointerDeviceKind.trackpad) { + setState(() { + _focusedCell = index; + _lastPointerKind = event.kind; + }); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + onExit: (_) { + if (_lastPointerKind == PointerDeviceKind.mouse || _lastPointerKind == PointerDeviceKind.trackpad) { + setState(() => _focusedCell = null); + } + }, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerHover: (event) => updateFocus(event, pressed: false), + onPointerDown: (event) => updateFocus(event, pressed: true), + child: CustomPaint( + size: Size(width, mapHeight), + painter: _PieceMapPainter( + pieces: pieces, + columns: columns, + cellSize: cellSize, + gap: _gap, + palette: palette, + focusedCell: focusedCell, + ), + ), + ), + ), + SizedBox( + height: 24, + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + focusedCell == null ? '' : _rangeLabel(focusedCell, pieces.length), + style: TextStyle(color: palette.textMuted, fontSize: 11), + ), + ), + ), + ], + ); + }, + ); + } + + String _rangeLabel(int displayIndex, int displayCount) { + final sourceCount = widget.pieceMap.pieceCount; + final startPiece = displayIndex * sourceCount ~/ displayCount; + final endPiece = math.max(startPiece + 1, (displayIndex + 1) * sourceCount ~/ displayCount) - 1; + final pieceLabel = startPiece == endPiece + ? context.l10n.piece(startPiece + 1) + : context.l10n.pieceRange(startPiece + 1, endPiece + 1); + final bytesPerPiece = widget.pieceMap.pieceSize; + if (bytesPerPiece <= 0) return pieceLabel; + final startByte = startPiece * bytesPerPiece; + final rawEnd = (endPiece + 1) * bytesPerPiece - 1; + final endByte = widget.totalBytes == null ? rawEnd : math.min(rawEnd, widget.totalBytes! - 1); + return '$pieceLabel · ${_formatBytes(startByte)}–${_formatBytes(math.max(startByte, endByte))}'; + } +} + +class _PieceMapPainter extends CustomPainter { + const _PieceMapPainter({ + required this.pieces, + required this.columns, + required this.cellSize, + required this.gap, + required this.palette, + required this.focusedCell, + }); + + final List pieces; + final int columns; + final double cellSize; + final double gap; + final AppPalette palette; + final int? focusedCell; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint(); + for (var index = 0; index < pieces.length; index++) { + final column = index % columns; + final row = index ~/ columns; + final cellRect = Rect.fromLTWH(column * (cellSize + gap), row * (cellSize + gap), cellSize, cellSize); + final rect = RRect.fromRectAndRadius(cellRect, const Radius.circular(2)); + final state = pieces[index]; + paint.color = switch (state) { + TaskPieceState.pending => palette.surfaceSoft, + TaskPieceState.completed => palette.success, + }; + canvas.drawRRect(rect, paint); + if (focusedCell == index) { + paint + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = palette.textPrimary; + canvas.drawRRect(rect, paint); + paint.style = PaintingStyle.fill; + } + } + } + + @override + bool shouldRepaint(covariant _PieceMapPainter oldDelegate) { + return oldDelegate.pieces != pieces || + oldDelegate.palette != palette || + oldDelegate.focusedCell != focusedCell || + oldDelegate.columns != columns || + oldDelegate.cellSize != cellSize; + } +} + +String _formatBytes(int bytes) { + return ByteSizeFormatter.format(bytes); +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/task_statistics_tab.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/task_statistics_tab.dart new file mode 100644 index 00000000..23a87cda --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_statistics/task_statistics_tab.dart @@ -0,0 +1,528 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../api/model/task_stats.dart'; +import '../../../../../core/utils/byte_size_formatter.dart'; +import '../../../../../shared/theme/app_design_tokens.dart'; +import '../../../../../shared/theme/app_palette.dart'; +import '../../../../../shared/widgets/app_tooltip.dart'; +import '../../../../../l10n/l10n.dart'; +import '../../../application/task_stats_provider.dart'; +import '../../../domain/task_record.dart'; +import '../../task_record_localizations.dart'; +import '../task_progress_bar.dart'; +import 'peer_table.dart'; +import 'piece_map.dart'; + +class TaskStatisticsTab extends ConsumerWidget { + const TaskStatisticsTab({super.key, required this.task, required this.mobile}); + + final TaskRecord task; + final bool mobile; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final statsAsync = ref.watch(taskStatsProvider((taskId: task.id, protocol: task.protocol))); + final stats = statsAsync.value; + final horizontalPadding = mobile + ? AppDesignTokens.taskDetailsMobilePadding + : AppDesignTokens.taskDetailsDesktopPadding; + + if (stats == null && statsAsync.isLoading) { + return Padding(padding: EdgeInsets.all(horizontalPadding), child: const _StatisticsSkeleton()); + } + if (stats == null) { + return const _StatisticsEmptyState(); + } + + return SingleChildScrollView( + key: PageStorageKey('task-statistics-${task.id}'), + padding: EdgeInsets.fromLTRB(horizontalPadding, horizontalPadding, horizontalPadding, 32), + child: switch (stats) { + HttpTaskStats() => _HttpStatistics(stats: stats, taskStatus: task.status), + BtTaskStats() => _BtStatistics(stats: stats, task: task), + Ed2kTaskStats() => _Ed2kStatistics(stats: stats), + }, + ); + } +} + +class _HttpStatistics extends StatelessWidget { + const _HttpStatistics({required this.stats, required this.taskStatus}); + + final HttpTaskStats stats; + final TaskStatus taskStatus; + + @override + Widget build(BuildContext context) { + final completed = stats.connections.where((connection) => connection.completed).length; + final failed = stats.connections.where((connection) => connection.failed).length; + final downloading = stats.connections.length - completed - failed; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(context.l10n.connectionStatus, trailing: context.l10n.connectionCount(stats.connections.length)), + const SizedBox(height: 12), + _SummaryGrid( + items: [ + _SummaryItem(context.l10n.downloading, math.max(0, downloading).toString()), + _SummaryItem(context.l10n.completed, completed.toString()), + _SummaryItem(context.l10n.failed, failed.toString()), + ], + ), + const SizedBox(height: 18), + HttpConnectionLanes(connections: stats.connections, taskDownloading: taskStatus == TaskStatus.downloading), + ], + ); + } +} + +class HttpConnectionLanes extends StatelessWidget { + const HttpConnectionLanes({super.key, required this.connections, required this.taskDownloading}); + + final List connections; + final bool taskDownloading; + + @override + Widget build(BuildContext context) { + if (connections.isEmpty) { + final palette = AppPalette.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Center( + child: Text(context.l10n.noPeers, style: TextStyle(color: palette.textMuted, fontSize: 13)), + ), + ); + } + return Column( + children: [ + for (var index = 0; index < connections.length; index++) + _ConnectionLane(index: index, connection: connections[index], taskDownloading: taskDownloading), + ], + ); + } +} + +class _ConnectionLane extends StatelessWidget { + const _ConnectionLane({required this.index, required this.connection, required this.taskDownloading}); + + final int index; + final HttpConnectionStats connection; + final bool taskDownloading; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final retrying = !connection.completed && !connection.failed && connection.retryTimes > 0; + final downloading = taskDownloading && !retrying && !connection.completed && !connection.failed; + final hasTotal = connection.total > 0; + final progress = hasTotal ? (connection.downloaded / connection.total).clamp(0.0, 1.0) : null; + final status = connection.failed + ? context.l10n.failed + : connection.completed + ? context.l10n.completed + : retrying + ? context.l10n.retrying + : context.l10n.downloading; + final statusColor = connection.failed + ? palette.error + : connection.completed + ? palette.success + : palette.brand; + return LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 330; + final amountWidth = narrow ? 54.0 : 66.0; + return Container( + key: ValueKey('http-connection-lane-$index'), + height: 38, + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + SizedBox( + width: 28, + child: Text( + '#${index + 1}', + style: TextStyle(color: palette.textPrimary, fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + SizedBox( + key: ValueKey('http-connection-status-$index'), + width: 18, + child: Center( + child: _ConnectionStatusIcon( + status: status, + color: statusColor, + retryTimes: connection.retryTimes, + failed: connection.failed, + retrying: retrying, + completed: connection.completed, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TaskProgressBar( + key: ValueKey('http-connection-progress-$index'), + value: progress, + indeterminate: downloading && !hasTotal, + shimmer: downloading, + height: 7, + trackColor: palette.brandTrack, + fillColor: palette.brandProgress, + highlightStartColor: palette.brandProgress, + highlightEndColor: palette.brandProgress, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: amountWidth, + child: Text( + _formatBytes(connection.downloaded), + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: narrow ? 10 : 11), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ConnectionStatusIcon extends StatelessWidget { + const _ConnectionStatusIcon({ + required this.status, + required this.color, + required this.retryTimes, + required this.failed, + required this.retrying, + required this.completed, + }); + + final String status; + final Color color; + final int retryTimes; + final bool failed; + final bool retrying; + final bool completed; + + @override + Widget build(BuildContext context) { + final icon = Semantics( + label: status, + child: Icon( + failed + ? Icons.error_rounded + : retrying + ? Icons.sync_problem_rounded + : completed + ? Icons.check_circle_rounded + : Icons.downloading_rounded, + size: 15, + color: color, + ), + ); + if (!failed && !retrying) return icon; + final message = failed + ? retryTimes > 0 + ? context.l10n.connectionFailedRetries(retryTimes) + : context.l10n.connectionFailed + : context.l10n.retryingCount(retryTimes); + return AppTooltip(message: message, child: icon); + } +} + +class _BtStatistics extends StatelessWidget { + const _BtStatistics({required this.stats, required this.task}); + + final BtTaskStats stats; + final TaskRecord task; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(context.l10n.pieceStatus), + const SizedBox(height: 16), + PieceMap(pieceMap: stats.pieceMap, totalBytes: task.totalBytes), + const _PieceLegend(), + const _SectionDivider(), + _SummaryGrid( + items: [ + _SummaryItem(context.l10n.peers, '${stats.activePeers} / ${stats.totalPeers}'), + _SummaryItem(context.l10n.seeders, stats.connectedSeeders.toString()), + _SummaryItem(context.l10n.leechers, stats.connectedLeechers.toString()), + _SummaryItem(context.l10n.uploadedAmount, _formatBytes(stats.seedBytes)), + _SummaryItem(context.l10n.shareRatio, stats.seedRatio.toStringAsFixed(2)), + _SummaryItem( + context.l10n.shareDuration, + formatTaskDuration(context.l10n, Duration(seconds: stats.seedTime)), + ), + ], + ), + const _SectionDivider(), + _SectionTitle('${context.l10n.peers} ${context.l10n.connectedPeers(stats.activePeers)}'), + const SizedBox(height: 12), + PeerTable(protocol: PeerTableProtocol.bt, peers: stats.peers), + ], + ); + } +} + +class _Ed2kStatistics extends StatelessWidget { + const _Ed2kStatistics({required this.stats}); + + final Ed2kTaskStats stats; + + @override + Widget build(BuildContext context) { + final completedPieces = stats.pieceMap.isEmpty ? '—' : stats.completedPieces.toString(); + final completed = stats.totalWanted > 0 + ? '${((stats.totalDone / stats.totalWanted).clamp(0.0, 1.0) * 100).round()}%' + : '—'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(context.l10n.pieceStatus), + const SizedBox(height: 16), + PieceMap(pieceMap: stats.pieceMap, totalBytes: stats.totalWanted > 0 ? stats.totalWanted : null), + const _PieceLegend(), + const _SectionDivider(), + _SummaryGrid( + items: [ + _SummaryItem(context.l10n.peers, stats.totalPeers.toString()), + _SummaryItem(context.l10n.active, stats.activePeers.toString()), + _SummaryItem(context.l10n.completedPieces, completedPieces), + _SummaryItem(context.l10n.speed, _formatRate(stats.downloadRate)), + _SummaryItem(context.l10n.uploadSpeed, _formatRate(stats.uploadRate)), + _SummaryItem(context.l10n.completed, completed), + ], + ), + const _SectionDivider(), + _SectionTitle('${context.l10n.peers} ${context.l10n.activePeers(stats.activePeers)}'), + const SizedBox(height: 12), + PeerTable(protocol: PeerTableProtocol.ed2k, peers: stats.peers), + ], + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.label, {this.trailing}); + + final String label; + final String? trailing; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + if (trailing != null) + Text( + trailing!, + style: TextStyle(color: palette.textMuted, fontSize: 11, fontWeight: FontWeight.w500), + ), + ], + ); + } +} + +class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container(height: 1, margin: const EdgeInsets.symmetric(vertical: 22), color: palette.border); + } +} + +class _SummaryItem { + const _SummaryItem(this.label, this.value); + + final String label; + final String value; +} + +class _SummaryGrid extends StatelessWidget { + const _SummaryGrid({required this.items}); + + final List<_SummaryItem> items; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 280 ? 3 : 2; + final width = constraints.maxWidth / columns; + return Wrap( + runSpacing: 18, + children: [ + for (var index = 0; index < items.length; index++) + SizedBox( + width: width, + child: Column( + crossAxisAlignment: switch (index % columns) { + 0 => CrossAxisAlignment.start, + final column when column == columns - 1 => CrossAxisAlignment.end, + _ => CrossAxisAlignment.center, + }, + children: [ + Text(items[index].label, style: TextStyle(color: palette.textMuted, fontSize: 11)), + const SizedBox(height: 5), + Text( + items[index].value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 15, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _PieceLegend extends StatelessWidget { + const _PieceLegend(); + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Wrap( + spacing: 18, + runSpacing: 8, + children: [ + _LegendItem(label: context.l10n.incomplete, color: palette.surfaceSoft), + _LegendItem(label: context.l10n.completed, color: palette.success), + ], + ); + } +} + +class _LegendItem extends StatelessWidget { + const _LegendItem({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 9, + height: 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: ColoredBox(color: color), + ), + ), + const SizedBox(width: 6), + Text(label, style: TextStyle(color: palette.textSecondary, fontSize: 11)), + ], + ); + } +} + +class _StatisticsSkeleton extends StatefulWidget { + const _StatisticsSkeleton(); + + @override + State<_StatisticsSkeleton> createState() => _StatisticsSkeletonState(); +} + +class _StatisticsSkeletonState extends State<_StatisticsSkeleton> with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final color = palette.surfaceSoft.withValues(alpha: 0.55 + _controller.value * 0.35); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SkeletonLine(width: 88, height: 16, color: color), + const SizedBox(height: 22), + _SkeletonLine(width: double.infinity, height: 74, color: color), + const SizedBox(height: 20), + _SkeletonLine(width: double.infinity, height: 110, color: color), + ], + ); + }, + ); + } +} + +class _SkeletonLine extends StatelessWidget { + const _SkeletonLine({required this.width, required this.height, required this.color}); + + final double width; + final double height; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)), + ); + } +} + +class _StatisticsEmptyState extends StatelessWidget { + const _StatisticsEmptyState(); + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Center( + child: Text(context.l10n.noStatistics, style: TextStyle(color: palette.textMuted, fontSize: 13)), + ); + } +} + +String _formatRate(int bytesPerSecond) => bytesPerSecond <= 0 ? '—' : '${_formatBytes(bytesPerSecond)}/s'; + +String _formatBytes(int bytes) { + return ByteSizeFormatter.format(bytes); +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart new file mode 100644 index 00000000..33a3bc82 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; + +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_http_headers_editor.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../l10n/l10n.dart'; +import '../../domain/task_record.dart'; + +class TaskUrlUpdate { + const TaskUrlUpdate({required this.url, required this.headers}); + + final String url; + final Map headers; +} + +Future showTaskUpdateUrlDialog(BuildContext context, TaskRecord task) async { + final urlController = TextEditingController(text: task.url); + final headersController = AppHttpHeadersController(headers: task.requestHeaders); + + final dialog = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) { + String? errorText; + return StatefulBuilder( + builder: (dialogContext, setDialogState) { + final palette = AppPalette.of(dialogContext); + void submit() { + final url = urlController.text.trim(); + if (url.isEmpty) { + setDialogState(() => errorText = dialogContext.l10n.downloadLinkValid); + return; + } + closeOverlay(dialogContext, TaskUrlUpdate(url: url, headers: headersController.toMap())); + } + + return AlertDialog( + title: Text(dialogContext.l10n.updateUrl), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680, maxHeight: 480), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(dialogContext.l10n.downloadLink, style: TextStyle(color: palette.textPrimary, fontSize: 12)), + const SizedBox(height: 6), + TextField(controller: urlController), + const SizedBox(height: 18), + AppHttpHeadersEditor( + controller: headersController, + label: dialogContext.l10n.httpHeader, + keyPrefix: 'update-task-http-header', + ), + if (errorText != null) ...[ + const SizedBox(height: 10), + Text(errorText!, style: TextStyle(color: palette.error, fontSize: 12)), + ], + ], + ), + ), + ), + actions: [ + SecondaryButton(onPressed: () => closeOverlay(dialogContext), child: Text(dialogContext.l10n.cancel)), + AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.updateAndResume)), + ], + ); + }, + ); + }, + ); + + try { + return await dialog.future; + } finally { + urlController.dispose(); + headersController.dispose(); + } +} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb new file mode 100644 index 00000000..d51c6b13 --- /dev/null +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ar", + "label": "العربية", + "error": "خطأ", + "tip": "تلميح", + "confirm": "تأكيد", + "confirmDelete": "هل أنت متأكد من الحذف؟", + "cancel": "إلغاء", + "on": "تشغيل", + "off": "إيقاف", + "selectAll": "تحديد الكل", + "select": "تحديد", + "task": "المهام", + "downloading": "جاري التحميل", + "downloaded": "تم التحميل", + "setting": "الإعدادات", + "donate": "تبرع", + "exit": "خروج", + "pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج", + "create": "إنشاء مهمة", + "directDownload": "تنزيل مباشر", + "advancedOptions": "خيارات متقدمة", + "followSettings": "اتباع الإعدادات", + "downloadLink": "رابط التحميل", + "downloadLinkValid": "الرجاء إدخال رابط التحميل", + "downloadLinkHit": "الرجاء إدخال رابط التحميل، سطر واحد لكل رابط@append", + "downloadLinkHitDesktop": "، أو اسحب ملف التورنت هنا مباشرة", + "download": "تنزيل", + "noFileSelected": "الرجاء تحديد ملف واحد على الأقل للمتابعة", + "noStoragePermission": "مطلوب إذن التخزين", + "selectFile": "تحديد ملف", + "rename": "إعادة تسمية", + "basic": "أساسي", + "advanced": "متقدم", + "general": "عام", + "downloadDir": "مجلد التنزيل", + "rememberLastDownloadDir": "حفظ دليل آخر مهمة تلقائيًا", + "downloadDirValid": "الرجاء تحديد مجلد التنزيل", + "connections": "الاتصالات", + "useServerCtime": "استخدام وقت الخادم لإنشاء الملف", + "maxRunning": "الحد الأقصى للمهام المتزامنة", + "defaultDirectDownload": "تفعيل التنزيل المباشر افتراضياً", + "autoStartTasks": "بدء المهام غير المكتملة تلقائياً عند التشغيل", + "autoTorrentEnable": "إنشاء مهام تورنت تلقائياً من ملفات .torrent", + "autoTorrentDeleteAfterDownload": "حذف ملف .torrent بعد إنشاء مهمة التورنت", + "autoDeleteMissingFileTasks": "حذف المهام ذات الملفات المفقودة تلقائياً", + "items": "{count} عناصر", + "subscribeTracker": "الاشتراك في متتبعات التورنت", + "subscribeFail": "فشل الاشتراك، يرجى التحقق من الشبكة أو المحاولة لاحقاً", + "update": "تحديث", + "updateDaily": "تحديث يومي", + "lastUpdate": "آخر تحديث: @time", + "addTracker": "إضافة متتبعة", + "addTrackerHit": "الرجاء إدخال عنوان URL للمتتبعة، سطر واحد لكل عنوان", + "ui": "واجهة المستخدم", + "theme": "المظهر", + "themeSystem": "النظام", + "themeLight": "فاتح", + "themeDark": "داكن", + "locale": "اللغة", + "notifyWhenNewVersion": "الإشعار بتوفر إصدار جديد", + "analyticsEnabled": "إحصائيات التحميل", + "analyticsEnabledDesc": "شارك بيانات الاستخدام المجهولة لمساعدتنا على التحسين", + "about": "حول", + "homepage": "الصفحة الرئيسية", + "version": "الإصدار", + "protocol": "البروتوكول", + "port": "المنفذ", + "apiToken": "رمز API", + "notSet": "غير مضبوط", + "set": "تعيين", + "portInUse": "المنفذ [@port] قيد الاستخدام، يرجى تغيير المنفذ", + "effectAfterRestart": "ساري بعد إعادة التشغيل", + "developer": "المطور", + "logDirectory": "مجلد السجلات", + "webhook": "Webhook", + "webhookEnable": "تمكين Webhook", + "webhookDesc": "إرسال إشعارات HTTP POST عند اكتمال المهام أو فشلها", + "webhookUrlHint": "أدخل عنوان URL لـ Webhook", + "webhookTest": "اختبار", + "webhookTestSuccess": "اختبار Webhook ناجح", + "webhookTestFail": "فشل اختبار Webhook", + "script": "سكريبت", + "scriptEnable": "تمكين السكريبت", + "scriptDesc": "تنفيذ سكريبتات مخصصة عند اكتمال التنزيل بنجاح", + "scriptPathHint": "أدخل مسار ملف السكريبت (مثال: /path/to/script.sh)", + "urlInvalid": "الرجاء إدخال عنوان URL صالح (HTTP أو HTTPS)", + "required": "هذا الحقل مطلوب", + "show": "عرض", + "continueAction": "متابعة", + "pause": "إيقاف مؤقت", + "startAll": "بدء الكل", + "pauseAll": "إيقاف الكل مؤقتاً", + "deleteTask": "حذف @count مهمة", + "deleteTaskTip": "الاحتفاظ بالملفات التي تم تحميلها", + "delete": "حذف", + "add": "إضافة", + "edit": "تعديل", + "newVersionTitle": "يتوفر إصدار جديد @version", + "newVersionUpdate": "تحديث الآن", + "newVersionLater": "لاحقاً", + "extensions": "الإضافات", + "extensionInstallUrl": "عنوان التثبيت", + "extensionInstallSuccess": "تم التثبيت بنجاح", + "extensionUpdateSuccess": "تم التحديث بنجاح", + "extensionDelete": "حذف الإضافة", + "extensionAlreadyLatest": "هذا هو أحدث إصدار بالفعل", + "extensionFind": "البحث عن إضافات", + "extensionDevelop": "تطوير إضافات", + "extensionStoreTitle": "متجر الإضافات", + "extensionStoreSection": "إضافات المتجر", + "extensionInstalledSection": "الإضافات المثبتة", + "extensionInstalledEmpty": "لا توجد إضافات مثبتة بعد", + "extensionStoreEmpty": "لم يتم العثور على إضافات مطابقة", + "extensionSearchHint": "البحث بالاسم أو العنوان أو الوصف أو المؤلف", + "extensionSortStars": "الأعلى تقييماً", + "extensionSortInstalls": "الأكثر تثبيتاً", + "extensionSortUpdated": "الأحدث تحديثاً", + "extensionSortToggle": "تبديل ترتيب الفرز", + "extensionLoadMore": "تحميل المزيد", + "extensionNoMore": "لا مزيد من الإضافات", + "extensionInstalled": "مثبتة", + "extensionCanUpdate": "يتوفر تحديث", + "extensionInstall": "تثبيت", + "extensionLoadLocal": "تثبيت مجلد محلي", + "extensionFilterAll": "الكل", + "extensionFilterMarket": "المتجر", + "extensionFilterInstalled": "المثبتة", + "extensionManualInstall": "تثبيت يدوي", + "extensionInstallTools": "خيارات التثبيت", + "history": "السجل", + "clearHistory": "مسح السجل", + "noHistoryFound": "لا يوجد سجل", + "serviceTitle": "خدمة التحميل", + "serviceText": "قيد التشغيل", + "network": "الشبكة", + "proxy": "الوكيل", + "noProxy": "بدون وكيل", + "systemProxy": "وكيل النظام", + "customProxy": "وكيل مخصص", + "server": "الخادم", + "username": "اسم المستخدم", + "password": "كلمة المرور", + "thanks": "شكر وتقدير", + "thanksDesc": "شكراً لجميع المساهمين الذين ساعدوا في بناء وتطوير مجتمع Gopeed!", + "browserExtension": "إضافة المتصفح", + "launchAtStartup": "التشغيل عند بدء التشغيل", + "runAsMenubarApp": "التشغيل كتطبيق شريط القوائم", + "runAsMenubarAppDesc": "إخفاء أيقونة الإرساء والتشغيل في شريط القوائم فقط", + "seedConfig": "إعدادات المشاركة", + "seedKeep": "الاستمرار في المشاركة حتى الإيقاف اليدوي", + "seedRatio": "نسبة المشاركة", + "seedTime": "مدة المشاركة (دقائق)", + "setAsDefaultBtClient": "تعيين كعميل تورنت افتراضي", + "taskDetail": "تفاصيل المهمة", + "taskName": "اسم المهمة", + "taskUrl": "رابط المهمة", + "downloadPath": "مسار التنزيل", + "skipVerifyCert": "تخطي التحقق من الشهادة", + "archives": "الملفات المضغوطة", + "autoExtract": "فك الضغط تلقائياً", + "archivePassword": "كلمة مرور الملف المضغوط", + "archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور", + "deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط", + "extracting": "جاري فك الضغط", + "extractDone": "تم فك الضغط بنجاح", + "extractError": "فشل فك الضغط", + "waitingParts": "في انتظار الأجزاء", + "name": "الاسم", + "size": "الحجم", + "unknown": "غير معروف", + "fileSelectedCount": "الملفات: ", + "fileSelectedSize": "الحجم: ", + "httpHeader": "رأس HTTP", + "httpHeaderName": "اسم الرأس", + "httpHeaderValue": "قيمة الرأس", + "login": "تسجيل الدخول", + "username_required": "الرجاء إدخال اسم المستخدم", + "password_required": "الرجاء إدخال كلمة المرور", + "login_success": "تم تسجيل الدخول بنجاح", + "login_failed": "فشل تسجيل الدخول، يرجى التحقق من اسم المستخدم وكلمة المرور", + "login_failed_network": "فشل تسجيل الدخول، يرجى التحقق من اتصال الشبكة", + "insertPlaceholder": "إدراج عنصر نائب", + "placeholderYear": "السنة الحالية", + "placeholderMonth": "الشهر الحالي (01-12)", + "placeholderDay": "اليوم الحالي (01-31)", + "placeholderDate": "التاريخ الكامل (YYYY-MM-DD)", + "example": "مثال: @value", + "downloadCategories": "فئات التنزيل", + "categoryMusic": "موسيقى", + "categoryVideo": "فيديو", + "categoryDocument": "مستندات", + "categoryProgram": "برامج", + "categoryName": "اسم الفئة", + "categoryPath": "مسار الفئة", + "builtInCategory": "لا يمكن حذف الفئة المضمنة", + "selectCategory": "اختر الفئة", + "quickDirectories": "مجلدات سريعة", + "githubMirror": "مرآة GitHub", + "githubMirrorEnable": "تمكين مرآة GitHub", + "githubMirrorDesc": "استخدام المرايا لتسريع تحميل محتوى GitHub", + "githubMirrorType": "نوع المرآة", + "githubMirrorUrl": "عنوان URL للمرآة", + "githubMirrorUrlHint": "أدخل عنوان URL للمرآة", + "updateUrl": "تحديث الرابط", + "updateUrlManual": "تحديث يدوي", + "updateUrlListen": "الاستماع للتحديثات", + "updateUrlListeningTip": "في انتظار تحديث الرابط", + "updateUrlCancelListen": "إلغاء الاستماع", + "updateUrlDialogHint": "أدخل رابط التحميل الجديد", + "pendingUpdateFound": "تم العثور على مهمة في انتظار التحديث", + "pendingUpdateConfirm": "المهمة \"{name}\" في انتظار تحديث الرابط. هل تريد تحديثها بالرابط الجديد؟", + "pendingUpdateYes": "تحديث المهمة", + "pendingUpdateNo": "إنشاء مهمة جديدة", + "ed2k": "ED2K", + "ed2kTcpPort": "منفذ استماع TCP", + "ed2kUdpPort": "منفذ استماع UDP", + "ed2kServerList": "عناوين الخوادم", + "ed2kServerMet": "مصادر Server.met", + "ed2kNodesDat": "مصادر nodes.dat", + "ed2kAutoPort": "تخصيص تلقائي", + "ed2kOnePerLine": "إدخال واحد في كل سطر", + "ed2kServersHint": "host:port، سطر واحد لكل خادم", + "ed2kServerMetHint": "عنوان URL لـ Server.met أو رابط قائمة خوادم ed2k، سطر واحد لكل رابط", + "ed2kNodesDatHint": "مسار أو عنوان URL لـ nodes.dat، سطر واحد لكل رابط", + "desktopNotification": "إشعارات سطح المكتب", + "backgroundLocationKeepAlive": "المعلومات الأساسية:", + "notificationTaskDone": "تمت المهمة", + "notificationTaskError": "خطأ في المهمة", + "appTitle": "مدير تنزيلات Gopeed", + "unsupportedWindowPayload": "طلب النافذة غير المدعومة", + "runtimeInitializationFailed": "غير قادر على تهيئة وقت تشغيل Gopeed. \n {error}", + "save": "أنقذوا", + "retry": "إعادة المحاولة", + "close": "إغلاق", + "back": "رجوع", + "open": "فتح", + "openFile": "فتح الملف", + "share": "مشاركة", + "copy": "نسخ", + "copied": "مُزَوَّل", + "refresh": "تحديث", + "status": "الحالة", + "createdAt": "وقت الإنشاء", + "speed": "السرعة", + "uploadSpeed": "سرعة التشغيل", + "remaining": "البقايا", + "downloadDuration": "مدة التنزيل", + "progress": "التقدم", + "failed": "فشل", + "completed": "مكتمل", + "waiting": "الانتظار", + "taskFailed": "فشل المهمة", + "unknownSize": "الحجم غير المعروف", + "secondsRemaining": "{count} ثانية متبقية", + "minutesRemaining": "{count} دقيقة متبقية", + "hoursRemaining": "{count} ساعة متبقية", + "secondsDuration": "{count} ثانية", + "minutesDuration": "{count} دقيقة", + "hoursDuration": "{count} ساعة", + "followSystem": "نظام المتابعة", + "basicSettings": "الإعدادات الأساسية", + "downloadSettings": "إعدادات التنزيل", + "advancedSettings": "الإعدادات المتقدمة", + "themeColor": "لون السمة", + "themeColorDescription": "تذوق لون اللهجة التطبيقية", + "browserExtensionDescription": "إرسال حمولات بروزر مباشرة إلى غوبيد بعد التركيب", + "desktopNotificationDescription": "تقديم إخطار النظام عندما تنتهي المهمة أو تفشل", + "backgroundLocationKeepAliveDescription": "استخدام الوصول إلى الموقع لإبقاء التحميلات نشطة في الخلفية", + "currentVersion": "الإصدار الحالي {version}", + "contributors": "المساهمون", + "viewContributors": "المساهمون في المشاهدة", + "maxRunningDescription": "العدد الأقصى للمهام التي يمكن تنزيلها في الوقت نفسه", + "defaultUserAgentDescription": "وكيل المستخدم الافتراضي لتنزيلات HTTP وHTTPS", + "listenPort": "اسمعني", + "onePerLine": "دخول واحد لكل خط", + "proxyProtocol": "بروتوكول المقاضاة", + "restartRequired": "هذا الوضع يبدأ بعد إعادة تشغيل التطبيق", + "socketPath": "\"سوكيت باث\"", + "listenAddress": "اسمع", + "currentConnection": "العلاقة الحالية", + "webhookPush": "نقاط نهاية Webhook", + "webhookPushDescription": "إرسال إخطارات إلى هذه النقاط النهائية عند إنجاز المهام أو فشلها", + "scriptExecution": "البرامج النصية", + "scriptExecutionDescription": "افحص هذه النصوص بعد ان يتم تحميلها بنجاح", + "addWebhook": "إضافة Webhook", + "editWebhook": "تعديل Webhook", + "addScript": "مضافا إليها:", + "editScript": "تعديل البرنامج النصي", + "openDirectory": "دليل مفتوح", + "browseFiles": "تصفح الملفات", + "updateEveryDay": "آخر المستجدات اليومية", + "updateVersion": "آخر", + "checkForUpdates": "التحقق من المستجدات", + "updating": ".....", + "colorGreen": "الأخضر", + "colorRed": "أحمر", + "colorRose": "روز", + "colorOrange": "غريب الأطوار", + "colorCyan": "سيان", + "colorBlue": "الأزرق", + "colorAmber": "كهرماني", + "colorPurple": "أرجواني", + "noExtensions": "لا تمديدات", + "searchExtensions": "تمديدات البحث...", + "extensionDeveloperGuide": "دليل تطوير الإرشاد", + "extensionInstallFromUrl": "تثبيت إضافة من URL", + "unableOpenExtensionDocs": "عدم القدرة على فتح دليل مطور التمديد", + "removeExtensionTitle": "تمديد غير ثابت؟", + "removeExtensionConfirm": "هل أنت متأكد أنك تريد إلغاء تثبيت \"{name}\"؟", + "uninstall": "إلغاء التثبيت", + "unableLoadExtensions": "غير قادر على تمديد الشحنات", + "filters": "عوامل التصفية", + "allTasks": "جميع المهام", + "searchDownloads": "ابحثوا عن التحميلات", + "searchHistory": "البحث في السجل…", + "addTask": "إضافة", + "emptyTaskList": "لا توجد مهام في هذه القائمة", + "noMatchingTasks": "لم تجد أي مهام متطابقة", + "taskList": "قائمة المهام", + "selectedCount": "{selected} / {total}", + "unableOpenPath": "غير قادر على فتح {path}", + "unableLocatePath": "غير قادر على تحديد موقع {path}", + "clearSelection": "الاختيار الواضح", + "deselectTask": "إلغاء التحديد", + "selectTask": "تحديد", + "updateManually": "تحديث يدويا", + "stopListening": "توقف عن الاستماع", + "listenForUrl": "استمع إلى (يورل)", + "waitingForReplacementUrl": "في انتظار استبدال", + "taskNotFound": "لم يتم العثور على", + "storagePath": "مخزن", + "directory": "المجلد", + "mode": "الوضع", + "keepOriginalName": "احتفظ بالاسم الأصلي", + "enterCount": "عدد المشتركين", + "chooseDownloadDirectory": "دليل التنزيل الاختراع", + "applicationStorage": "مساحة تخزين التطبيق", + "applicationStorageDescription": "مساحة تخزين موثوقة للتطبيق؛ تُحذف الملفات عند إلغاء تثبيت التطبيق", + "publicDownloadsDescription": "تظل الملفات محفوظة بعد إلغاء التثبيت؛ يعتمد التوفر على جهاز Android", + "downloadsDirectoryUnavailable": "لا يمكن لـ Gopeed الكتابة إلى Download/Gopeed على هذا الجهاز", + "enterValue": "القيمة الدفترية", + "trackers": "المتتبعات", + "oneTrackerPerLine": "متعقب واحد لكل خط", + "pasteDownloadLinks": "معذره هي تي بي أو هي تي بي أو ماجينيت أو اسقط ملفاً سيئاً هنا", + "unableReadTorrent": "غير قادر على قراءة ملف التورنت: {error}", + "torrentOnly": "فقط الملفات المخزنة مدعومة", + "torrentDataEmpty": "بيانات الملف المُتعذّب فارغة", + "torrentPathUnavailable": "غير قادر على الوصول إلى طريق الملف المُتعذّب", + "selectAtLeastOneFile": "اختار ملف واحد على الأقل", + "resolvedFiles": "الملفات المبتغاة", + "resolvedFileCount": "{count}", + "resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار", + "noFiles": "لا توجد ملفات", + "createHistory": "تاريخ الخلق", + "noHistory": "لا تاريخ", + "directDownloadDescription": "حل المشاكل وخلق المهام على الفور", + "taskProgress": "التقدم المحرز", + "updateAndResume": "تحديث واستئناف", + "httpHeaders": "HTTP headers", + "piece": "قطعة {number}", + "pieceRange": "القطع {start}-{end}", + "connectionStatus": "الحالة", + "connectionCount": "{count}", + "retrying": "إعادة التأهيل", + "connectionFailed": "فشل الإرتباط", + "connectionFailedRetries": "بعد إعادة محاولة {count}", + "retryingCount": "· إعادة المحاولة {count}", + "pieceStatus": "حالة القطع", + "peers": "الأقران", + "seeders": "المزوّدون", + "leechers": "المستنزفون", + "downloadedAmount": "تحميل", + "uploadedAmount": "محملة", + "shareRatio": "نسبة الحصة", + "shareDuration": "مدة المشاركة", + "connectedPeers": "{count} متصل", + "active": "النشاط", + "completedPieces": "قطع الغيار المنجزة", + "activePeers": "{count} نشط", + "incomplete": "غير مكتمل", + "address": "العنوان", + "client": "الجهة المختصة", + "pieces": "قطع", + "source": "المصدر", + "fileRelevance": "صلة الملف", + "taskDetails": "تفاصيل المهام", + "taskDetailsInfoTab": "التفاصيل", + "taskDetailsFilesTab": "الملفات", + "taskDetailsStatsTab": "الإحصاءات", + "noStatistics": "لا توجد إحصاءات متاحة", + "noPeers": "لا صلة", + "deleteTasksTitle": "هل تريد حذف مهام {count}؟", + "keepFilesDescription": "قم فقط بإزالة سجل المهمة من Gopeed", + "readingRuntimeStatus": "قراءة حالة وقت العمل...", + "apiConfigSaved": "تم إنقاذها، أعيدي تطبيقها", + "socketPathRequired": "الطريق السريع مطلوب", + "serverRequired": "مطلوب من الموظف", + "portRangeError": "أدخل ميناء ما بين 0 و 65535", + "portInUseValue": "{port} قيد الاستخدام بالفعل", + "backgroundLocationPermissionRequired": "دائماً ما تسمح بالوصول إلى الموقع مطلوب لتحميل المعلومات الأساسية", + "scriptPath": "مسار البرنامج النصي", + "logDirectoryUnavailable": "دليل اللوزة غير مبدئي", + "unableOpenExternalLink": "عدم القدرة على فتح الصلة الخارجية", + "neverUpdated": "لم يستكمل أبداً", + "lastUpdated": "آخر تحديث: {time}", + "unableLoadSettings": "غير قادر على التحميل", + "fieldRequired": "{field}", + "addGithubMirror": "ممر جيت هوب", + "editGithubMirror": "تعديل مرآة GitHub", + "confirmDeleteTitle": "تأكيد حذف؟", + "confirmDeleteNamed": "هل أنت متأكد أنك تريد حذف \"{name}\"؟", + "categoryFieldsRequired": "اسم الفئة وطريقها مطلوبان", + "addCategory": "الفئة", + "editCategory": "الفئة", + "deleteCategoryTitle": "تحذف الفئة؟", + "updateFailed": "لقد فشل التحديث، حاول مرة أخرى لاحقاً", + "newVersionAvailable": "ويمكن الحصول على نسخة جديدة", + "releaseNotes": "ما الجديد", + "noReleaseNotes": "لا توجد مذكرات إطلاق", + "preparingDownload": "تحضير التحميل...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "خدمة Gopeed في الخلفية", + "androidForegroundNotificationTitle": "Gopeed قيد التشغيل", + "androidForegroundNotificationText": "يمكن متابعة التنزيلات في الخلفية" +} diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb new file mode 100644 index 00000000..c7f9d231 --- /dev/null +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ca", + "label": "Català", + "error": "Error", + "tip": "Consell", + "confirm": "Confirma", + "confirmDelete": "Esteu segur que voleu suprimir-lo?", + "cancel": "Cancel·la", + "on": "Activat", + "off": "Desactivat", + "selectAll": "Selecciona-ho tot", + "select": "Selecciona", + "task": "Tasques", + "downloading": "s'està baixant", + "downloaded": "s'ha baixat", + "setting": "Configuració", + "donate": "Donació", + "exit": "Surt", + "pressBackAgainToExit": "Torna a prémer enrere per sortir", + "create": "Crea una tasca", + "directDownload": "Baixada directa", + "advancedOptions": "Opcions avançades", + "followSettings": "Segueix la configuració", + "downloadLink": "Enllaç de baixada", + "downloadLinkValid": "Introduïu l'enllaç de baixada", + "downloadLinkHit": "Introduïu l'enllaç de baixada, un per línia@append", + "downloadLinkHitDesktop": "o arrossegueu el fitxer torrent aquí", + "download": "Baixa", + "noFileSelected": "Seleccioneu almenys un fitxer per continuar", + "noStoragePermission": "Cal permís d'emmagatzematge", + "selectFile": "Seleccioneu el fitxer", + "rename": "Canvia el nom", + "basic": "Bàsic", + "advanced": "Avançat", + "general": "General", + "downloadDir": "Carpeta de baixades", + "rememberLastDownloadDir": "Desa automàticament l'últim directori de tasca", + "downloadDirValid": "Seleccioneu la carpeta de baixades", + "connections": "Connexions", + "useServerCtime": "Utilitza l'hora del servidor per a la creació de fitxers", + "maxRunning": "Màxim de tasques en execució", + "defaultDirectDownload": "Activa la baixada directa per defecte", + "autoStartTasks": "Inicia automàticament les tasques incompletes a l'inici", + "autoTorrentEnable": "Crea automàticament tasques BT a partir de fitxers .torrent", + "autoTorrentDeleteAfterDownload": "Suprimeix el fitxer .torrent després de crear la tasca BT", + "autoDeleteMissingFileTasks": "Suprimeix automàticament les tasques dels fitxers que falten", + "items": "{count} elements", + "subscribeTracker": "Subscripció al rastrejador", + "subscribeFail": "La subscripció ha fallat. Comproveu la xarxa o torneu-ho a provar més tard", + "update": "Actualitza", + "updateDaily": "Actualització diària", + "lastUpdate": "Última actualització: @time", + "addTracker": "Afegeix un rastrejador", + "addTrackerHit": "Introduïu la URL del servidor del rastrejador, un per línia", + "ui": "IU", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Clar", + "themeDark": "Fosc", + "locale": "Idioma", + "notifyWhenNewVersion": "Notificació d'actualitzacions", + "analyticsEnabled": "Puja les estadístiques", + "analyticsEnabledDesc": "Compartiu dades d'ús anònimes per ajudar-nos a millorar", + "about": "Quant a", + "homepage": "Pàgina d'inici", + "version": "Versió", + "protocol": "Protocol", + "port": "Port", + "apiToken": "Token de l'API", + "notSet": "Sense congigurar", + "set": "Configurat", + "portInUse": "El port [@port] està en ús, canvieu-lo", + "effectAfterRestart": "Els efectes s'aplicaran després de reiniciar", + "developer": "Desenvolupador", + "logDirectory": "Carpeta dels registres", + "webhook": "Webhook", + "webhookEnable": "Activa el Webhook", + "webhookDesc": "Envia notificacions HTTP POST quan les tasques finalitzin o fallin", + "webhookUrlHint": "Introduïu la URL del webhook", + "webhookTest": "Prova", + "webhookTestSuccess": "La prova del webhook ha estat correcta", + "webhookTestFail": "La prova del webhook ha fallat", + "script": "Seqüència d'ordres", + "scriptEnable": "Activa la seqüència d'ordres", + "scriptDesc": "Executa seqüències d'ordres personalitzades quan les baixades finalitzin correctament", + "scriptPathHint": "Introduïu la ruta del fitxer de seqüències d'ordres (p.ex., /ruta/al/fitxer.sh)", + "urlInvalid": "Introduïu una URL HTTP o HTTPS vàlida", + "required": "Aquest camp és obligatori", + "show": "Mostra", + "continueAction": "Contina", + "pause": "Pausa", + "startAll": "Inicia-ho tot", + "pauseAll": "Pausa-ho tot", + "deleteTask": "Suprimeix @count tasques", + "deleteTaskTip": "Mantén els fitxers baixats", + "delete": "Suprimeix", + "add": "Afegeix", + "edit": "Edita", + "newVersionTitle": "Descobriu la nova versió @version", + "newVersionUpdate": "Actualitza ara", + "newVersionLater": "Més tard", + "extensions": "Extensions", + "extensionInstallUrl": "URL d'instal·lació", + "extensionInstallSuccess": "S'ha instal·lat amb èxit", + "extensionUpdateSuccess": "S'ha actualitzat amb èxit", + "extensionDelete": "Suprimeix l'extensió", + "extensionAlreadyLatest": "Ja està actualitzada", + "extensionFind": "Cerca extensions", + "extensionDevelop": "Desenvolupa extensions", + "extensionStoreTitle": "Botiga d'extensions", + "extensionStoreSection": "Extensions de la botiga", + "extensionInstalledSection": "Extensions instal·lades", + "extensionInstalledEmpty": "Encara no s'ha instal·lat cap extensió", + "extensionStoreEmpty": "No s'han trobat extensions coincidents", + "extensionSearchHint": "Cerca per nom, títol, descripció o autor", + "extensionSortStars": "Més estrelles", + "extensionSortInstalls": "Més instal·lacions", + "extensionSortUpdated": "Actualitzada recentment", + "extensionSortToggle": "Commuta la direcció d'ordenació", + "extensionLoadMore": "Carrega'n més", + "extensionNoMore": "No hi ha més extensions", + "extensionInstalled": "Instal·lada", + "extensionCanUpdate": "Actualització disponible", + "extensionInstall": "Instal·la", + "extensionLoadLocal": "Instal·la de d'una carpeta local", + "extensionFilterAll": "Totes", + "extensionFilterMarket": "Mercat", + "extensionFilterInstalled": "Instal·lada", + "extensionManualInstall": "Instal·lació manual", + "extensionInstallTools": "Opcions d'instal·lació", + "history": "Historial", + "clearHistory": "Neteja l'historial", + "noHistoryFound": "No s'ha trobat cap historial", + "serviceTitle": "Servei de baixades", + "serviceText": "S'està executant", + "network": "Xarxa", + "proxy": "Servidor intermediari", + "noProxy": "Sense servidor intermediari", + "systemProxy": "Servidor intermediari del sistema", + "customProxy": "Servidor intermediari personalitzat", + "server": "Servidor", + "username": "Nom d'usuari", + "password": "Contrasenya", + "thanks": "Gràcies", + "thanksDesc": "Gràcies a tots els col·laboradors que han ajudat a construir i desenvolupar la comunitat del Gopeed!", + "browserExtension": "Extensió del navegador", + "launchAtStartup": "Executa a l'inici", + "runAsMenubarApp": "Executa com a aplicació de barra de menú", + "runAsMenubarAppDesc": "Amaga la icona de l'acoblador i executa només a la barra de menú", + "seedConfig": "Configuració de les llavors", + "seedKeep": "Continua sembrant fins que s'aturi manualment", + "seedRatio": "Proporció de les llavors", + "seedTime": "Temps de la llavor (minuts)", + "setAsDefaultBtClient": "Estableix com a client BT per defecte", + "taskDetail": "Detall de la tasca", + "taskName": "Nom de la tasca", + "taskUrl": "URL de la tasca", + "downloadPath": "Ruta de la baixada", + "skipVerifyCert": "Omet la verificació del certificat", + "archives": "Comprimits", + "autoExtract": "Extreu els comprimits automàticament", + "archivePassword": "Contrasenya del comprimit", + "archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya", + "deleteAfterExtract": "Suprimeix el comprimit després de l'extracció", + "extracting": "S'està extraient", + "extractDone": "Extracció completada", + "extractError": "Extracció fallida", + "waitingParts": "S'està esperant a les parts", + "name": "Nom", + "size": "Mida", + "unknown": "Desconegut", + "fileSelectedCount": "Fitxers: ", + "fileSelectedSize": "Mida: ", + "httpHeader": "Capçalera", + "httpHeaderName": "Nomm de la capçalera", + "httpHeaderValue": "Valor de la capçalera", + "login": "Inici de sessió", + "username_required": "Introduïu el vostre nom d'usuari", + "password_required": "Introduïu la vostra contrasenya", + "login_success": "Inici de sessió correcte", + "login_failed": "No s'ha pogut iniciar la sessió, comproveu el vostre nom d'usuari i contrasenya", + "login_failed_network": "No s'ha pogut iniciar la sessió, comproveu la vostra connexió de xarxa", + "insertPlaceholder": "Inseriu un marcador de posició", + "placeholderYear": "Any actual", + "placeholderMonth": "Mes actual (01-12)", + "placeholderDay": "Dia actual (01-31)", + "placeholderDate": "Data completa (AAAA-MM-DD)", + "example": "p.ex. @value", + "downloadCategories": "Categories de baixades", + "categoryMusic": "Música", + "categoryVideo": "Vídeo", + "categoryDocument": "Document", + "categoryProgram": "Programa", + "categoryName": "Nom de la categoria", + "categoryPath": "Ruta de la categoria", + "builtInCategory": "La categoria integrada no es pot suprimir", + "selectCategory": "Seleccioneu una categoria", + "quickDirectories": "Carpetes ràpides", + "githubMirror": "Rèplica del GitHub", + "githubMirrorEnable": "Activa la rèplica del GitHub", + "githubMirrorDesc": "Utilitzeu rèpliques per accelerar les baixades de contingut del GitHub", + "githubMirrorType": "Tipus de rèplica", + "githubMirrorUrl": "URL de la rèplica", + "githubMirrorUrlHint": "Introduïu la URL de la rèplica", + "updateUrl": "Actualitza la URL", + "updateUrlManual": "Actualització manual", + "updateUrlListen": "Escolta l'actualització", + "updateUrlListeningTip": "S'està esperant l'actualització de l'URL", + "updateUrlCancelListen": "Cancel·la l'escolta", + "updateUrlDialogHint": "Introduïu una nova URL de baixada", + "pendingUpdateFound": "S'ha trobat una tasca d'actualització pendent", + "pendingUpdateConfirm": "La tasca \"{name}\" està esperant l'actualització de la URL. Voleu actualitzar-la amb la URL nova?", + "pendingUpdateYes": "Actualitza la tasca", + "pendingUpdateNo": "Crea una tasca nova", + "ed2k": "ED2K", + "ed2kTcpPort": "Port d'escolta TCP", + "ed2kUdpPort": "Port d'escolta UDP", + "ed2kServerList": "Adreces del servidor", + "ed2kServerMet": "Fonts del Server.met", + "ed2kNodesDat": "Fonts del nodes.dat", + "ed2kAutoPort": "Assignació automàtica", + "ed2kOnePerLine": "Una entrada per línia", + "ed2kServersHint": "amfitrió:port, un per línia", + "ed2kServerMetHint": "URL del Server.met o enllaç del llistat de servidors ed2k, un per línia", + "ed2kNodesDatHint": "Ruta o URL del nodes.dat, una per línia", + "desktopNotification": "Notificacions d'escriptori", + "backgroundLocationKeepAlive": "Base de baixades Conserva la vida", + "notificationTaskDone": "Tasca completada", + "notificationTaskError": "Error de tasca", + "appTitle": "Gestor de baixades de Gopeed", + "unsupportedWindowPayload": "Petició de finestra no implementada", + "runtimeInitializationFailed": "No s'ha pogut inicialitzar el temps d'execució de Gopeed. \n {error}", + "save": "Desa", + "retry": "Torna a provar", + "close": "Tanca", + "back": "Enrere", + "open": "Obre", + "openFile": "Obre el fitxer", + "share": "Comparteix", + "copy": "Copia", + "copied": "Copiat", + "refresh": "Refresca", + "status": "Estat", + "createdAt": "Data de creació", + "speed": "Velocitat", + "uploadSpeed": "Velocitat de pujada", + "remaining": "Restant", + "downloadDuration": "Durada de la baixada", + "progress": "Progrés", + "failed": "Ha fallat", + "completed": "Completat", + "waiting": "Esperant", + "taskFailed": "La tasca ha fallat", + "unknownSize": "Mida desconeguda", + "secondsRemaining": "{count} segons restants", + "minutesRemaining": "{count} minuts restants", + "hoursRemaining": "{count} hores restants", + "secondsDuration": "{count} segons", + "minutesDuration": "{count} minuts", + "hoursDuration": "{count} hores", + "followSystem": "Segueix el sistema", + "basicSettings": "Paràmetres bàsics", + "downloadSettings": "Arranjament de baixada", + "advancedSettings": "Arranjament avançat", + "themeColor": "Color del tema", + "themeColorDescription": "Personalitza el color d' accent de l' aplicació", + "browserExtensionDescription": "Envia les baixades del navegador directament a Gopeed després de la instal· lació", + "desktopNotificationDescription": "Mostra una notificació del sistema quan una tasca completa o falla", + "backgroundLocationKeepAliveDescription": "Usa l' accés de localització per a mantenir les baixades actives en el fons", + "currentVersion": "Versió actual {version}", + "contributors": "Col· laboradors", + "viewContributors": "Visualitza col· laboradors", + "maxRunningDescription": "Nombre màxim de tasques que poden descarregar alhora", + "defaultUserAgentDescription": "Agent d'usuari predeterminat per a baixades HTTP i HTTPS", + "listenPort": "Escolta el port", + "onePerLine": "Una entrada per línia", + "proxyProtocol": "Protocol intermediari", + "restartRequired": "Aquesta opció tindrà efecte després de reiniciar l' aplicació", + "socketPath": "Ruta del sòcol", + "listenAddress": "Escolta l' adreça", + "currentConnection": "Connexió actual", + "webhookPush": "Punts d' acabament web", + "webhookPushDescription": "Envia notificacions POST HTTP a aquests punts finals quan les tasques acabin o fallin", + "scriptExecution": "Scripts", + "scriptExecutionDescription": "Executa aquests scripts després de completar la baixada correctament", + "addWebhook": "Afegeix un descommut web", + "editWebhook": "Edita el descomuna Web", + "addScript": "Afegeix script", + "editScript": "Edita l' script", + "openDirectory": "Obre directori", + "browseFiles": "Navega pels fitxers", + "updateEveryDay": "Actualitza cada dia", + "updateVersion": "Actualitza", + "checkForUpdates": "Comprova actualitzacions", + "updating": "S' està actualitzant...", + "colorGreen": "Verd", + "colorRed": "Vermell", + "colorRose": "Rose", + "colorOrange": "Taronja", + "colorCyan": "Cian", + "colorBlue": "Blau", + "colorAmber": "Ambre", + "colorPurple": "Purpra", + "noExtensions": "Sense extensions", + "searchExtensions": "Extensions de cerca...", + "extensionDeveloperGuide": "Guia de desenvolupadors de l' extensió", + "extensionInstallFromUrl": "Instal· la l' extensió des de l' URL", + "unableOpenExtensionDocs": "No s' ha pogut obrir la guia del desenvolupador de l' extensió", + "removeExtensionTitle": "Desinstal· la l' extensió?", + "removeExtensionConfirm": "Esteu segur que voleu desinstal·lar “{name}”?", + "uninstall": "Desinstal· la", + "unableLoadExtensions": "No s' han pogut carregar les extensions", + "filters": "Filtres", + "allTasks": "Totes les tasques", + "searchDownloads": "Cerca baixades...", + "searchHistory": "Cerca a l'historial…", + "addTask": "Afegeix tasca", + "emptyTaskList": "No hi ha tasques en aquesta llista", + "noMatchingTasks": "No s' han trobat tasques coincidents", + "taskList": "Llista de tasques", + "selectedCount": "{selected} / {total} seleccionat", + "unableOpenPath": "No es pot obrir {path}", + "unableLocatePath": "No s'ha pogut localitzar {path}", + "clearSelection": "Neteja la selecció", + "deselectTask": "Desselecciona", + "selectTask": "Selecciona", + "updateManually": "Actualitza manualment", + "stopListening": "Deixa d' escoltar", + "listenForUrl": "Escolta l' URL", + "waitingForReplacementUrl": "S' està esperant un URL de substitució", + "taskNotFound": "No s' ha trobat la tasca", + "storagePath": "Ruta d' emmagatzematge", + "directory": "Directori", + "mode": "Mode", + "keepOriginalName": "Mantenir el nom original", + "enterCount": "Número d' entrada", + "chooseDownloadDirectory": "Escolliu el directori de baixada", + "applicationStorage": "Emmagatzematge de l'aplicació", + "applicationStorageDescription": "Emmagatzematge fiable de l'aplicació; els fitxers s'eliminen en desinstal·lar-la", + "publicDownloadsDescription": "Conserva els fitxers després de desinstal·lar; la disponibilitat depèn del dispositiu Android", + "downloadsDirectoryUnavailable": "Gopeed no pot escriure a Download/Gopeed en aquest dispositiu", + "enterValue": "Introduïu el valor", + "trackers": "Seguidors", + "oneTrackerPerLine": "Un URL del seguidor per línia", + "pasteDownloadLinks": "Enganxa els enllaços HTTP, HTTPS, o MAGNET, o deixa aquí un fitxer torrent", + "unableReadTorrent": "No es pot llegir el fitxer torrent: {error}", + "torrentOnly": "Només es permeten fitxers.torrent", + "torrentDataEmpty": "El fitxer torrent de dades és buit", + "torrentPathUnavailable": "No s' ha pogut accedir al camí del fitxer torrent", + "selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer", + "resolvedFiles": "Fitxers resolts", + "resolvedFileCount": "{count}", + "resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar", + "noFiles": "Sense fitxers", + "createHistory": "Crea un historial", + "noHistory": "Sense historial", + "directDownloadDescription": "Omet la resolució i crea les tasques immediatament", + "taskProgress": "Progrés de la tasca", + "updateAndResume": "Actualitza i reprèn", + "httpHeaders": "capçaleres HTTP", + "piece": "Peça {number}", + "pieceRange": "Peces {start}–{end}", + "connectionStatus": "Estat de la connexió", + "connectionCount": "{count} connexions", + "retrying": "S' està tornant a provar", + "connectionFailed": "Ha fallat la connexió", + "connectionFailedRetries": "La connexió ha fallat després que {count} reintents", + "retryingCount": "Reintent · {count} Reintents", + "pieceStatus": "Estat de la peça", + "peers": "Parells", + "seeders": "Sembradors", + "leechers": "Sangoneres", + "downloadedAmount": "Baixat", + "uploadedAmount": "Pujat", + "shareRatio": "Ràtio de compartició", + "shareDuration": "Durada de compartició", + "connectedPeers": "{count} connectat", + "active": "Actiu", + "completedPieces": "Les peces completades", + "activePeers": "{count} actiu", + "incomplete": "Incompleta", + "address": "Adreça", + "client": "Client", + "pieces": "Trossos", + "source": "Origen", + "fileRelevance": "Rellevància dels fitxers", + "taskDetails": "Detalls de la tasca", + "taskDetailsInfoTab": "Detalls", + "taskDetailsFilesTab": "Fitxers", + "taskDetailsStatsTab": "Estadístiques", + "noStatistics": "No hi ha estadístiques disponibles", + "noPeers": "Sense connexions", + "deleteTasksTitle": "Vols suprimir les tasques {count}?", + "keepFilesDescription": "Elimina només el registre de la tasca de Gopeed", + "readingRuntimeStatus": "S' està llegint l' estat de l' hora d' execució...", + "apiConfigSaved": "S' han desat els paràmetres de l' API. Reinicieu l' aplicació per aplicar- los", + "socketPathRequired": "Cal la ruta del sòcol", + "serverRequired": "Es requereix el servidor", + "portRangeError": "Introduïu un port entre 0 i 65535", + "portInUseValue": "El port {port} ja està en ús", + "backgroundLocationPermissionRequired": "Permet sempre l' accés a la localització per a baixades de fons", + "scriptPath": "Camí de l' script", + "logDirectoryUnavailable": "El directori del registre no s' ha inicialitzat", + "unableOpenExternalLink": "No s' ha pogut obrir l' enllaç extern", + "neverUpdated": "Mai s' ha actualitzat", + "lastUpdated": "Última actualització: {time}", + "unableLoadSettings": "No s' ha pogut carregar els paràmetres", + "fieldRequired": "{field}", + "addGithubMirror": "Afegeix una rèplica del GitHub", + "editGithubMirror": "Edita el mirall del GitHub", + "confirmDeleteTitle": "Confirmar l' esborrat?", + "confirmDeleteNamed": "Esteu segur que voleu suprimir “{name}”?", + "categoryFieldsRequired": "Es requereix nom i camí de categoria", + "addCategory": "Afegeix categoria", + "editCategory": "Edita categoria", + "deleteCategoryTitle": "Esborro la categoria?", + "updateFailed": "Ha fallat l' actualització. Torneu- ho a provar més tard", + "newVersionAvailable": "Hi ha disponible una nova versió", + "releaseNotes": "Quèts nous", + "noReleaseNotes": "No hi ha notes disponibles", + "preparingDownload": "S' està preparant la baixada...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Servei en segon pla de Gopeed", + "androidForegroundNotificationTitle": "Gopeed s'està executant", + "androidForegroundNotificationText": "Les baixades poden continuar en segon pla" +} diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb new file mode 100644 index 00000000..34221c3f --- /dev/null +++ b/ui/flutter/lib/l10n/app_de.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "de", + "label": "Deutsch", + "error": "Fehler", + "tip": "Tipp", + "confirm": "Bestätigen", + "confirmDelete": "Möchten Sie wirklich löschen?", + "cancel": "Abbrechen", + "on": "An", + "off": "Aus", + "selectAll": "Alle Auswählen", + "select": "Auswählen", + "task": "Aufgaben", + "downloading": "Wird Heruntergeladen", + "downloaded": "Heruntergeladen", + "setting": "Einstellungen", + "donate": "Spenden", + "exit": "Beenden", + "pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden", + "create": "Aufgabe Erstellen", + "directDownload": "Direkter Download", + "advancedOptions": "Erweiterte Einstellungen", + "followSettings": "Einstellungen Folgen", + "downloadLink": "Download-Link", + "downloadLinkValid": "Bitte geben Sie den Download-Link ein", + "downloadLinkHit": "Bitte geben Sie den Download-Link ein, einen pro Zeile@append", + "downloadLinkHitDesktop": ", oder ziehen Sie die Torrent-Datei direkt hierher", + "download": "Download", + "noFileSelected": "Bitte wählen Sie mindestens eine Datei aus, um fortzufahren", + "noStoragePermission": "Speicherberechtigung erforderlich", + "selectFile": "Datei auswählen", + "rename": "Umbenennen", + "basic": "Basic", + "advanced": "Fortschrittlich", + "general": "Allgemein", + "downloadDir": "Download-Verzeichnis", + "rememberLastDownloadDir": "Letztes Aufgabenverzeichnis automatisch speichern", + "downloadDirValid": "Bitte wählen Sie das Download-Verzeichnis", + "connections": "Anschlüsse", + "useServerCtime": "Serverzeit für die Dateierstellung verwenden", + "maxRunning": "Maximale Anzahl ausgeführter Aufgaben", + "defaultDirectDownload": "Direkten Download standardmäßig aktivieren", + "autoStartTasks": "Unvollständige Aufgaben beim Start automatisch starten", + "autoTorrentEnable": "BT-Aufgaben automatisch aus .torrent-Dateien erstellen", + "autoTorrentDeleteAfterDownload": ".torrent-Datei nach BT-Aufgabenerstellung löschen", + "autoDeleteMissingFileTasks": "Fehlende Dateiaufgaben automatisch löschen", + "items": "{count} Artikel", + "subscribeTracker": "Abonnement-Tracker", + "subscribeFail": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie das Netzwerk oder versuchen Sie es später erneut", + "update": "Aktualisieren", + "updateDaily": "Tägliche Aktualisierung", + "lastUpdate": "Letzte Aktualisierung: @time", + "addTracker": "Tracker hinzufügen", + "addTrackerHit": "Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile", + "ui": "Benutzeroberfläche", + "theme": "Thema", + "themeSystem": "-System", + "themeLight": "Lichtmodus", + "themeDark": "Dunkler Modus", + "locale": "Sprache", + "notifyWhenNewVersion": "Bei Aktualisierungen benachrichtigen", + "analyticsEnabled": "Upload-Statistiken", + "analyticsEnabledDesc": "Geben Sie anonyme Nutzungsdaten weiter, um uns bei der Verbesserung zu helfen", + "about": "Über", + "homepage": "Homepage", + "version": "-Version", + "protocol": "Protokoll", + "port": "-Port", + "apiToken": "API-Token", + "notSet": "NS", + "set": "SET", + "portInUse": "Port [@port] wird verwendet, bitte ändern Sie den Port", + "effectAfterRestart": "Wirkung nach Neustart", + "developer": "Entwickler-in", + "logDirectory": "Protokollverzeichnis", + "webhook": "Webhook", + "webhookEnable": "Webhook aktivieren", + "webhookDesc": "Senden Sie HTTP-POST-Benachrichtigungen, wenn Aufgaben abgeschlossen werden oder fehlschlagen", + "webhookUrlHint": "Geben Sie die Webhook-URL ein", + "webhookTest": "-Test", + "webhookTestSuccess": "Webhook-Test erfolgreich", + "webhookTestFail": "Webhook-Test fehlgeschlagen", + "script": "Script", + "scriptEnable": "Skript aktivieren", + "scriptDesc": "Führen Sie benutzerdefinierte Skripts aus, wenn die Downloads erfolgreich abgeschlossen wurden", + "scriptPathHint": "Enter script file path (e.g., /path/to/script.sh)", + "urlInvalid": "Bitte geben Sie eine gültige HTTP- oder HTTPS-URL ein", + "required": "This field is required", + "show": "Zeigen", + "continueAction": "Weitermachen", + "pause": "Pausieren", + "startAll": "Alles starten", + "pauseAll": "Alle pausieren", + "deleteTask": "@count Aufgaben löschen", + "deleteTaskTip": "Heruntergeladene Dateien behalten", + "delete": "Löschen", + "add": "Hinzugefügt", + "edit": "Bearbeiten", + "newVersionTitle": "Entdecken Sie die neue Version @version", + "newVersionUpdate": "Jetzt aktualisieren", + "newVersionLater": "Später", + "extensions": "Erweiterungen", + "extensionInstallUrl": "Installations-URL", + "extensionInstallSuccess": "Erfolgreich installiert", + "extensionUpdateSuccess": "Erfolgreich aktualisiert", + "extensionDelete": "Erweiterung löschen", + "extensionAlreadyLatest": "Es ist bereits die neueste Version", + "extensionFind": "Erweiterungen suchen", + "extensionDevelop": "Erweiterungen entwickeln", + "extensionStoreTitle": "Extension Store", + "extensionStoreSection": "Store Extensions", + "extensionInstalledSection": "Installed Extensions", + "extensionInstalledEmpty": "No extensions installed yet", + "extensionStoreEmpty": "No matching extensions found", + "extensionSearchHint": "Search by name, title, description or author", + "extensionSortStars": "Die meisten Sterne", + "extensionSortInstalls": "Die meisten Installationen", + "extensionSortUpdated": "Kürzlich aktualisiert", + "extensionSortToggle": "Toggle sort direction", + "extensionLoadMore": "Load More", + "extensionNoMore": "No more extensions", + "extensionInstalled": "Installed", + "extensionCanUpdate": "Update Available", + "extensionInstall": "Install", + "extensionLoadLocal": "Lokalen Ordner installieren", + "extensionFilterAll": "All", + "extensionFilterMarket": "Markt", + "extensionFilterInstalled": "Installiert", + "extensionManualInstall": "Manual Install", + "extensionInstallTools": "Install Options", + "history": "Verlauf", + "clearHistory": "Verlauf löschen", + "noHistoryFound": "Kein Verlauf gefunden", + "serviceTitle": "Download-Service", + "serviceText": "Läuft", + "network": "Netzwerk", + "proxy": "-Proxy", + "noProxy": "Kein Proxy", + "systemProxy": "System-Proxy", + "customProxy": "Benutzerdefinierter Proxy", + "server": "-Server", + "username": "Benutzername", + "password": "Passwort", + "thanks": "Danke", + "thanksDesc": "Vielen Dank an alle Mitwirkenden, die beim Aufbau und der Entwicklung der Gopeed-Community geholfen haben!", + "browserExtension": "Browsererweiterung", + "launchAtStartup": "Beim Start starten", + "runAsMenubarApp": "Als Menüleisten-App ausführen", + "runAsMenubarAppDesc": "Dock-Symbol ausblenden und nur in der Menüleiste ausführen", + "seedConfig": "Seed-Konfiguration", + "seedKeep": "Weiter-seeden bis manuell gestoppt", + "seedRatio": "Seed Verhältniss", + "seedTime": "Seed-Zeit (Minuten)", + "setAsDefaultBtClient": "Als Standard-BT-Client festlegen", + "taskDetail": "Aufgabendetails", + "taskName": "Aufgabenname", + "taskUrl": "Aufgaben-URL", + "downloadPath": "Download-Pfad", + "skipVerifyCert": "Zertifikatsüberprüfung überspringen", + "archives": "Archive", + "autoExtract": "Archive automatisch entpacken", + "archivePassword": "Archiv-Passwort", + "archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt", + "deleteAfterExtract": "Archiv nach Entpacken löschen", + "extracting": "Wird entpackt", + "extractDone": "Entpacken abgeschlossen", + "extractError": "Entpacken fehlgeschlagen", + "waitingParts": "Warte auf Teile", + "name": "Name", + "size": "Größe", + "unknown": "Unbekannt", + "fileSelectedCount": "Dateien: ", + "fileSelectedSize": "Größe: ", + "httpHeader": "Header", + "httpHeaderName": "Header-Name", + "httpHeaderValue": "Header-Wert", + "login": "Login", + "username_required": "Bitte geben Sie Ihren Benutzernamen ein", + "password_required": "Bitte geben Sie Ihr Passwort ein", + "login_success": "Anmeldung erfolgreich", + "login_failed": "Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort", + "login_failed_network": "Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung", + "insertPlaceholder": "Platzhalter einfügen", + "placeholderYear": "Aktuelles Jahr", + "placeholderMonth": "Aktueller Monat (01-12)", + "placeholderDay": "Aktueller Tag (01-31)", + "placeholderDate": "Vollständiges Datum (JJJJ-MM-TT)", + "example": "z.B. @value", + "downloadCategories": "Download-Kategorien", + "categoryMusic": "Musik", + "categoryVideo": "-Video", + "categoryDocument": "Dokument", + "categoryProgram": "Programm", + "categoryName": "Kategoriename", + "categoryPath": "Kategoriepfad", + "builtInCategory": "Integrierte Kategorie kann nicht gelöscht werden", + "selectCategory": "Kategorie auswählen", + "quickDirectories": "Schnellordner", + "githubMirror": "GitHub-Spiegel", + "githubMirrorEnable": "GitHub Mirror aktivieren", + "githubMirrorDesc": "Verwenden Sie Spiegel, um das Herunterladen von GitHub-Inhalten zu beschleunigen", + "githubMirrorType": "Spiegeltyp", + "githubMirrorUrl": "Spiegel-URL", + "githubMirrorUrlHint": "Enter mirror URL", + "updateUrl": "Aktualisierungs-URL", + "updateUrlManual": "Manual Update", + "updateUrlListen": "Listen Update", + "updateUrlListeningTip": "Waiting for URL update", + "updateUrlCancelListen": "Cancel Listen", + "updateUrlDialogHint": "Enter new download URL", + "pendingUpdateFound": "Pending Update Task Found", + "pendingUpdateConfirm": "Task \"{name}\" is waiting for URL update. Do you want to update it with the new URL?", + "pendingUpdateYes": "Update Task", + "pendingUpdateNo": "Create New Task", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP-Listen-Port", + "ed2kUdpPort": "UDP-Listen-Port", + "ed2kServerList": "Serveradressen", + "ed2kServerMet": "Server.met-Quellen", + "ed2kNodesDat": "nodes.dat Quellen", + "ed2kAutoPort": "Auto assign", + "ed2kOnePerLine": "One entry per line", + "ed2kServersHint": "host:port, einer pro Zeile", + "ed2kServerMetHint": "Server.met-URL oder ed2k-Serverlisten-Link, einer pro Zeile", + "ed2kNodesDatHint": "nodes.dat-Pfad oder URL, einer pro Zeile", + "desktopNotification": "Desktop-Benachrichtigungen", + "backgroundLocationKeepAlive": "Hintergrund-Download Keep Alive", + "notificationTaskDone": "Aufgabe abgeschlossen", + "notificationTaskError": "Aufgabenfehler", + "appTitle": "Gopeed Download-Manager", + "unsupportedWindowPayload": "Nicht unterstützte Fensteranforderung", + "runtimeInitializationFailed": "Die Gopeed-Laufzeit konnte nicht initialisiert werden. \n {error}", + "save": "Speichern", + "retry": "Wiederholen", + "close": "Schließen", + "back": "Zurück", + "open": "Offen", + "openFile": "Datei öffnen", + "share": "Teilen", + "copy": "Kopie", + "copied": "Kopiert", + "refresh": "Aktualisieren", + "status": "-Status", + "createdAt": "Erstellungszeit", + "speed": "Geschwindigkeit", + "uploadSpeed": "Upload-Geschwindigkeit", + "remaining": "Verbleibend", + "downloadDuration": "Download-Dauer", + "progress": "Fortschritt", + "failed": "Fehlgeschlagen", + "completed": "Abgeschlossen", + "waiting": "Waiting", + "taskFailed": "Task failed", + "unknownSize": "Unbekannte Größe", + "secondsRemaining": "{count, plural, =1{1 second remaining} other{{count} seconds remaining}}", + "minutesRemaining": "{count, plural, =1{1 minute remaining} other{{count} minutes remaining}}", + "hoursRemaining": "{count, plural, =1{1 hour remaining} other{{count} hours remaining}}", + "secondsDuration": "{count, plural, =1{1 Sekunde} other{{count} Sekunden}}", + "minutesDuration": "{count, plural, =1{1 Minute} other{{count} Minuten}}", + "hoursDuration": "{count, plural, =1{1 Stunde} other{{count} Stunden}}", + "followSystem": "Folgen Sie dem System", + "basicSettings": "Grundeinstellungen", + "downloadSettings": "Download-Einstellungen", + "advancedSettings": "Erweiterte Einstellungen", + "themeColor": "Themenfarbe", + "themeColorDescription": "Passen Sie die Akzentfarbe der App an", + "browserExtensionDescription": "Browser-Downloads nach der Installation direkt an Gopeed senden", + "desktopNotificationDescription": "Zeigt eine Systembenachrichtigung an, wenn eine Aufgabe abgeschlossen wird oder fehlschlägt", + "backgroundLocationKeepAliveDescription": "Verwenden Sie den Standortzugriff, um Downloads im Hintergrund aktiv zu halten", + "currentVersion": "Aktuelle Version {version}", + "contributors": "Mitwirkende", + "viewContributors": "Mitwirkende anzeigen", + "maxRunningDescription": "Maximale Anzahl von Aufgaben, die gleichzeitig heruntergeladen werden dürfen", + "defaultUserAgentDescription": "Standard-Benutzeragent für HTTP- und HTTPS-Downloads", + "listenPort": "Listen-Port", + "onePerLine": "Ein Eintrag pro Zeile", + "proxyProtocol": "Proxy-Protokoll", + "restartRequired": "Diese Einstellung wird nach dem Neustart der App wirksam", + "socketPath": "Sockelpfad", + "listenAddress": "Abhöradresse", + "currentConnection": "Aktuelle Verbindung", + "webhookPush": "Webhook-Endpunkte", + "webhookPushDescription": "Senden Sie HTTP-POST-Benachrichtigungen an diese Endpunkte, wenn Aufgaben abgeschlossen werden oder fehlschlagen", + "scriptExecution": "-Skripte", + "scriptExecutionDescription": "Führen Sie diese Skripts aus, nachdem ein Download erfolgreich abgeschlossen wurde", + "addWebhook": "Webhook hinzufügen", + "editWebhook": "Webhook bearbeiten", + "addScript": "Skript hinzufügen", + "editScript": "Skript bearbeiten", + "openDirectory": "Open Directory", + "browseFiles": "Dateien durchsuchen", + "updateEveryDay": "Tägliches Update", + "updateVersion": "-Update", + "checkForUpdates": "Nach Updates suchen", + "updating": "wird aktualisiert…", + "colorGreen": "Grün", + "colorRed": "Rot", + "colorRose": "Rose", + "colorOrange": "Orange", + "colorCyan": "Cyan", + "colorBlue": "Blau", + "colorAmber": "Bernstein", + "colorPurple": "Lila", + "noExtensions": "Keine Erweiterungen", + "searchExtensions": "Sucherweiterungen…", + "extensionDeveloperGuide": "Extension-Entwicklerhandbuch", + "extensionInstallFromUrl": "Erweiterung von URL installieren", + "unableOpenExtensionDocs": "Das Entwicklerhandbuch für die Erweiterung kann nicht geöffnet werden", + "removeExtensionTitle": "Erweiterung deinstallieren?", + "removeExtensionConfirm": "Sind Sie sicher, dass Sie „{name}“ deinstallieren möchten?", + "uninstall": "Deinstallation", + "unableLoadExtensions": "Erweiterungen können nicht geladen werden", + "filters": "Filter", + "allTasks": "Alle Aufgaben", + "searchDownloads": "Downloads suchen…", + "searchHistory": "Verlauf durchsuchen…", + "addTask": "Aufgabe hinzufügen", + "emptyTaskList": "Keine Aufgaben in dieser Liste", + "noMatchingTasks": "Keine passenden Aufgaben gefunden", + "taskList": "Aufgabenliste", + "selectedCount": "{selected} / {total} ausgewählt", + "unableOpenPath": "{path} kann nicht geöffnet werden", + "unableLocatePath": "{path} konnte nicht gefunden werden", + "clearSelection": "Auswahl löschen", + "deselectTask": "Abwählen", + "selectTask": "Auswählen", + "updateManually": "Manuell aktualisieren", + "stopListening": "Hör auf zuzuhören", + "listenForUrl": "Auf URL achten", + "waitingForReplacementUrl": "Warten auf eine Ersatz-URL", + "taskNotFound": "Aufgabe nicht gefunden", + "storagePath": "Speicherpfad", + "directory": "-Verzeichnis", + "mode": "-Modus", + "keepOriginalName": "Originalnamen beibehalten", + "enterCount": "Geben Sie die Anzahl ein", + "chooseDownloadDirectory": "Wählen Sie das Download-Verzeichnis", + "applicationStorage": "App-Speicher", + "applicationStorageDescription": "Zuverlässiger App-Speicher; Dateien werden bei der Deinstallation der App gelöscht", + "publicDownloadsDescription": "Behält Dateien nach der Deinstallation; die Verfügbarkeit hängt vom Android-Gerät ab", + "downloadsDirectoryUnavailable": "Gopeed kann auf diesem Gerät nicht in Download/Gopeed schreiben", + "enterValue": "Wert eingeben", + "trackers": "Tracker", + "oneTrackerPerLine": "Eine Tracker-URL pro Zeile", + "pasteDownloadLinks": "Fügen Sie hier HTTP-, HTTPS- oder MAGNET-Links ein oder legen Sie eine Torrent-Datei ab", + "unableReadTorrent": "Torrent-Datei kann nicht gelesen werden: {error}", + "torrentOnly": "Es werden nur Torrent-Dateien unterstützt", + "torrentDataEmpty": "Torrent-Dateidaten sind leer", + "torrentPathUnavailable": "Auf den Torrent-Dateipfad kann nicht zugegriffen werden", + "selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus", + "resolvedFiles": "Aufgelöste Dateien", + "resolvedFileCount": "{count}-Dateien behoben", + "resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste", + "noFiles": "Keine Dateien", + "createHistory": "Verlauf erstellen", + "noHistory": "Keine Historie", + "directDownloadDescription": "Überspringen Sie die Lösung und erstellen Sie sofort Aufgaben", + "taskProgress": "Aufgabenfortschritt", + "updateAndResume": "Update und Fortfahren", + "httpHeaders": "HTTP-Header", + "piece": "Stück {number}", + "pieceRange": "Teile {start}–{end}", + "connectionStatus": "Verbindungsstatus", + "connectionCount": "{count}-Verbindungen", + "retrying": "Wiederholen", + "connectionFailed": "Verbindung fehlgeschlagen", + "connectionFailedRetries": "Verbindung ist nach {count}-Wiederholungsversuchen fehlgeschlagen", + "retryingCount": "Wiederholungsversuch · {count} Wiederholungsversuche", + "pieceStatus": "Stückstatus", + "peers": "Kollegen", + "seeders": "Sämaschinen", + "leechers": "Blutegel", + "downloadedAmount": "Heruntergeladen", + "uploadedAmount": "Hochgeladen", + "shareRatio": "Freigabeverhältnis", + "shareDuration": "Freigabedauer", + "connectedPeers": "{count} angeschlossen", + "active": "Aktiv", + "completedPieces": "Abgeschlossene Stücke", + "activePeers": "{count} aktiv", + "incomplete": "Unvollständig", + "address": "Adresse", + "client": "-Client", + "pieces": "Stück", + "source": "Quelle", + "fileRelevance": "Dateirelevanz", + "taskDetails": "Aufgabendetails", + "taskDetailsInfoTab": "Details", + "taskDetailsFilesTab": "-Dateien", + "taskDetailsStatsTab": "Statistik", + "noStatistics": "Keine Statistik verfügbar", + "noPeers": "Keine Verbindungen", + "deleteTasksTitle": "{count}-Aufgaben löschen?", + "keepFilesDescription": "Entfernen Sie nur den Aufgabendatensatz aus Gopeed", + "readingRuntimeStatus": "Laufzeitstatus wird gelesen…", + "apiConfigSaved": "API-Einstellungen gespeichert. Starten Sie die App neu, um sie anzuwenden", + "socketPathRequired": "Socket-Pfad ist erforderlich", + "serverRequired": "-Server ist erforderlich", + "portRangeError": "Geben Sie einen Port zwischen 0 und 65535 ein", + "portInUseValue": "Port {port} wird bereits verwendet", + "backgroundLocationPermissionRequired": "Für Hintergrund-Downloads ist immer Standortzugriff zulassen erforderlich", + "scriptPath": "Skriptpfad", + "logDirectoryUnavailable": "Protokollverzeichnis ist nicht initialisiert", + "unableOpenExternalLink": "Der externe Link kann nicht geöffnet werden", + "neverUpdated": "Nie aktualisiert", + "lastUpdated": "Letzte Aktualisierung: {time}", + "unableLoadSettings": "Einstellungen können nicht geladen werden", + "fieldRequired": "{field} ist erforderlich", + "addGithubMirror": "GitHub Mirror hinzufügen", + "editGithubMirror": "GitHub Mirror bearbeiten", + "confirmDeleteTitle": "Löschen bestätigen?", + "confirmDeleteNamed": "Sind Sie sicher, dass Sie „{name}“ löschen möchten?", + "categoryFieldsRequired": "Kategoriename und -pfad sind erforderlich", + "addCategory": "Kategorie hinzufügen", + "editCategory": "Kategorie bearbeiten", + "deleteCategoryTitle": "Kategorie löschen?", + "updateFailed": "Aktualisierung fehlgeschlagen. Bitte versuchen Sie es später noch einmal", + "newVersionAvailable": "Eine neue Version ist verfügbar", + "releaseNotes": "Was ist neu?", + "noReleaseNotes": "Keine Versionshinweise verfügbar", + "preparingDownload": "Download wird vorbereitet…", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed-Hintergrunddienst", + "androidForegroundNotificationTitle": "Gopeed wird ausgeführt", + "androidForegroundNotificationText": "Downloads können im Hintergrund fortgesetzt werden" +} diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb new file mode 100644 index 00000000..ab493411 --- /dev/null +++ b/ui/flutter/lib/l10n/app_en.arb @@ -0,0 +1,610 @@ +{ + "@@locale": "en", + "label": "English", + "error": "Error", + "tip": "Tip", + "confirm": "Confirm", + "confirmDelete": "Are you sure you want to delete?", + "cancel": "Cancel", + "on": "On", + "off": "Off", + "selectAll": "Select All", + "select": "Select", + "task": "Tasks", + "downloading": "Downloading", + "downloaded": "Downloaded", + "setting": "Settings", + "donate": "Donate", + "exit": "Exit", + "pressBackAgainToExit": "Press back again to exit", + "create": "Create Task", + "directDownload": "Direct Download", + "advancedOptions": "Advanced Options", + "followSettings": "Follow Settings", + "downloadLink": "Download Link", + "downloadLinkValid": "Please enter the download link", + "downloadLinkHit": "Please enter the download link, one per line@append", + "downloadLinkHitDesktop": ", or drag the torrent file here directly", + "download": "Download", + "noFileSelected": "Please select at least one file to continue", + "noStoragePermission": "Storage permission required", + "selectFile": "Select File", + "rename": "Rename", + "basic": "Basic", + "advanced": "Advanced", + "general": "General", + "downloadDir": "Download Directory", + "rememberLastDownloadDir": "Automatically save last task directory", + "downloadDirValid": "Please select the download directory", + "connections": "Connections", + "useServerCtime": "Use server time for file creation", + "maxRunning": "Concurrent Downloads", + "defaultDirectDownload": "Direct Download by Default", + "autoStartTasks": "Auto-start incomplete tasks on startup", + "autoTorrentEnable": "Auto create BT tasks from .torrent files", + "autoTorrentDeleteAfterDownload": "Delete .torrent file after BT task creation", + "autoDeleteMissingFileTasks": "Auto delete missing file tasks", + "items": "{count} items", + "@items": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "subscribeTracker": "Subscribe Tracker", + "subscribeFail": "Subscribe failed, please check network or try again later", + "update": "Update", + "updateDaily": "Update daily", + "lastUpdate": "Last update: @time", + "addTracker": "Add Tracker", + "addTrackerHit": "Please enter the tracker server url, one per line", + "ui": "UI", + "theme": "Theme", + "themeSystem": "System", + "themeLight": "Light", + "themeDark": "Dark", + "locale": "Language", + "notifyWhenNewVersion": "Notify for updates", + "analyticsEnabled": "Usage Analytics", + "analyticsEnabledDesc": "Share anonymous usage data to help us improve", + "about": "About", + "homepage": "Homepage", + "version": "Version", + "protocol": "Protocol", + "port": "Port", + "apiToken": "API Token", + "notSet": "NS", + "set": "SET", + "portInUse": "Port [@port] is in use, please change the port", + "effectAfterRestart": "Effect after restart", + "developer": "Developer", + "logDirectory": "Log Directory", + "webhook": "Webhook", + "webhookEnable": "Enable Webhooks", + "webhookDesc": "Send HTTP POST notifications when tasks complete or fail", + "webhookUrlHint": "Enter webhook URL", + "webhookTest": "Test", + "webhookTestSuccess": "Webhook test successful", + "webhookTestFail": "Webhook test failed", + "script": "Script", + "scriptEnable": "Enable Scripts", + "scriptDesc": "Execute custom scripts when downloads complete successfully", + "scriptPathHint": "Enter script file path (e.g., /path/to/script.sh)", + "urlInvalid": "Please enter a valid HTTP or HTTPS URL", + "required": "This field is required", + "show": "Show", + "continueAction": "Continue", + "pause": "Pause", + "startAll": "Start All", + "pauseAll": "Pause All", + "deleteTask": "Delete @count tasks", + "deleteTaskTip": "Keep downloaded files", + "delete": "Delete", + "add": "Add", + "edit": "Edit", + "newVersionTitle": "Discover new version @version", + "newVersionUpdate": "Update Now", + "newVersionLater": "Later", + "extensions": "Extensions", + "extensionInstallUrl": "Install URL", + "extensionInstallSuccess": "Installed successfully", + "extensionUpdateSuccess": "Updated successfully", + "extensionDelete": "Delete Extension", + "extensionAlreadyLatest": "It's already the latest version", + "extensionFind": "Find Extensions", + "extensionDevelop": "Develop Extensions", + "extensionStoreTitle": "Extension Store", + "extensionStoreSection": "Store Extensions", + "extensionInstalledSection": "Installed Extensions", + "extensionInstalledEmpty": "No extensions installed yet", + "extensionStoreEmpty": "No matching extensions found", + "extensionSearchHint": "Search by name, title, description or author", + "extensionSortStars": "Most Stars", + "extensionSortInstalls": "Most Installs", + "extensionSortUpdated": "Recently Updated", + "extensionSortToggle": "Toggle sort direction", + "extensionLoadMore": "Load More", + "extensionNoMore": "No more extensions", + "extensionInstalled": "Installed", + "extensionCanUpdate": "Update Available", + "extensionInstall": "Install", + "extensionLoadLocal": "Install Local Folder", + "extensionFilterAll": "All", + "extensionFilterMarket": "Market", + "extensionFilterInstalled": "Installed", + "extensionManualInstall": "Manual Install", + "extensionInstallTools": "Install Options", + "history": "History", + "clearHistory": "Clear History", + "noHistoryFound": "No History Found", + "serviceTitle": "Download Service", + "serviceText": "Running", + "network": "Network", + "proxy": "Proxy", + "noProxy": "No Proxy", + "systemProxy": "System Proxy", + "customProxy": "Custom Proxy", + "server": "Server", + "username": "Username", + "password": "Password", + "thanks": "Thanks", + "thanksDesc": "Thanks to all the contributors who have helped build and develop the Gopeed community!", + "browserExtension": "Browser Extension", + "launchAtStartup": "Launch at Startup", + "runAsMenubarApp": "Run as menubar app", + "runAsMenubarAppDesc": "Hide dock icon and run in menubar only", + "seedConfig": "Seed Config", + "seedKeep": "Keep seeding until manually stopped", + "seedRatio": "Seed ratio", + "seedTime": "Seed time (minutes)", + "setAsDefaultBtClient": "Set as the default BT client", + "taskDetail": "Task Detail", + "taskName": "Task Name", + "taskUrl": "Task URL", + "downloadPath": "Download Path", + "skipVerifyCert": "Skip Certificate Verification", + "archives": "Archives", + "autoExtract": "Extract Archives Automatically", + "archivePassword": "Archive Password", + "archivePasswordHint": "Leave empty if not password protected", + "deleteAfterExtract": "Delete Archives After Extraction", + "extracting": "Extracting", + "extractDone": "Extraction completed", + "extractError": "Extraction failed", + "waitingParts": "Waiting for parts", + "name": "Name", + "size": "Size", + "unknown": "Unknown", + "fileSelectedCount": "Files: ", + "fileSelectedSize": "Size: ", + "httpHeader": "Header", + "httpHeaderName": "Header Name", + "httpHeaderValue": "Header Value", + "login": "Login", + "username_required": "Please enter your username", + "password_required": "Please enter your password", + "login_success": "Login successful", + "login_failed": "Login failed, please check your username and password", + "login_failed_network": "Login failed, please check your network connection", + "insertPlaceholder": "Insert Placeholder", + "placeholderYear": "Current year", + "placeholderMonth": "Current month (01-12)", + "placeholderDay": "Current day (01-31)", + "placeholderDate": "Full date (YYYY-MM-DD)", + "example": "e.g. @value", + "downloadCategories": "Download Categories", + "categoryMusic": "Music", + "categoryVideo": "Video", + "categoryDocument": "Document", + "categoryProgram": "Program", + "categoryName": "Category Name", + "categoryPath": "Category Path", + "builtInCategory": "Built-in category cannot be deleted", + "selectCategory": "Select Category", + "quickDirectories": "Quick folders", + "githubMirror": "GitHub Mirror", + "githubMirrorEnable": "Enable GitHub Mirror", + "githubMirrorDesc": "Use mirrors to accelerate GitHub content downloads", + "githubMirrorType": "Mirror Type", + "githubMirrorUrl": "Mirror URL", + "githubMirrorUrlHint": "Enter mirror URL", + "updateUrl": "Update URL", + "updateUrlManual": "Manual Update", + "updateUrlListen": "Listen Update", + "updateUrlListeningTip": "Waiting for URL update", + "updateUrlCancelListen": "Cancel Listen", + "updateUrlDialogHint": "Enter new download URL", + "pendingUpdateFound": "Pending Update Task Found", + "pendingUpdateConfirm": "Task \"{name}\" is waiting for URL update. Do you want to update it with the new URL?", + "@pendingUpdateConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pendingUpdateYes": "Update Task", + "pendingUpdateNo": "Create New Task", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP Listen Port", + "ed2kUdpPort": "UDP Listen Port", + "ed2kServerList": "Server Addresses", + "ed2kServerMet": "Server.met Sources", + "ed2kNodesDat": "nodes.dat Sources", + "ed2kAutoPort": "Auto assign", + "ed2kOnePerLine": "One entry per line", + "ed2kServersHint": "host:port, one per line", + "ed2kServerMetHint": "Server.met URL or ed2k serverlist link, one per line", + "ed2kNodesDatHint": "nodes.dat path or URL, one per line", + "desktopNotification": "Desktop Notifications", + "backgroundLocationKeepAlive": "Background Downloads", + "notificationTaskDone": "Task completed", + "notificationTaskError": "Task error", + "appTitle": "Gopeed Download Manager", + "unsupportedWindowPayload": "Unsupported window request", + "runtimeInitializationFailed": "Unable to initialize Gopeed runtime.\n{error}", + "save": "Save", + "retry": "Retry", + "close": "Close", + "back": "Back", + "open": "Open", + "openFile": "Open File", + "share": "Share", + "copy": "Copy", + "copied": "Copied", + "refresh": "Refresh", + "status": "Status", + "createdAt": "Created", + "speed": "Speed", + "uploadSpeed": "Upload speed", + "remaining": "Remaining", + "downloadDuration": "Download duration", + "progress": "Progress", + "failed": "Failed", + "completed": "Completed", + "waiting": "Waiting", + "taskFailed": "Task failed", + "unknownSize": "Unknown size", + "secondsRemaining": "{count, plural, =1{1 second remaining} other{{count} seconds remaining}}", + "minutesRemaining": "{count, plural, =1{1 minute remaining} other{{count} minutes remaining}}", + "hoursRemaining": "{count, plural, =1{1 hour remaining} other{{count} hours remaining}}", + "secondsDuration": "{count, plural, =1{1 second} other{{count} seconds}}", + "minutesDuration": "{count, plural, =1{1 minute} other{{count} minutes}}", + "hoursDuration": "{count, plural, =1{1 hour} other{{count} hours}}", + "followSystem": "System Default", + "basicSettings": "Basic", + "downloadSettings": "Downloads", + "advancedSettings": "Advanced", + "themeColor": "Accent Color", + "themeColorDescription": "Customize the app accent color", + "browserExtensionDescription": "Send browser downloads directly to Gopeed after installation", + "desktopNotificationDescription": "Show a system notification when a task completes or fails", + "backgroundLocationKeepAliveDescription": "Use location access to keep downloads active in the background", + "currentVersion": "Current version {version}", + "contributors": "Contributors", + "viewContributors": "View contributors", + "maxRunningDescription": "Maximum number of tasks that may download at the same time", + "defaultUserAgentDescription": "Default User-Agent for HTTP and HTTPS downloads", + "listenPort": "Listening Port", + "onePerLine": "One entry per line", + "proxyProtocol": "Protocol", + "restartRequired": "This setting takes effect after restarting the app", + "socketPath": "Socket Path", + "listenAddress": "Listen Address", + "currentConnection": "Current Endpoint", + "webhookPush": "Endpoints", + "webhookPushDescription": "Send HTTP POST notifications to these endpoints when tasks complete or fail", + "scriptExecution": "Scripts", + "scriptExecutionDescription": "Run these scripts after a download completes successfully", + "addWebhook": "Add Webhook", + "editWebhook": "Edit Webhook", + "addScript": "Add Script", + "editScript": "Edit Script", + "openDirectory": "Open Directory", + "browseFiles": "Browse Files", + "updateEveryDay": "Update daily", + "updateVersion": "Update", + "checkForUpdates": "Check for Updates", + "updating": "Updating…", + "colorGreen": "Green", + "colorRed": "Red", + "colorRose": "Rose", + "colorOrange": "Orange", + "colorCyan": "Cyan", + "colorBlue": "Blue", + "colorAmber": "Amber", + "colorPurple": "Purple", + "noExtensions": "No extensions", + "searchExtensions": "Search extensions…", + "extensionDeveloperGuide": "Extension developer guide", + "extensionInstallFromUrl": "Install extension from URL", + "unableOpenExtensionDocs": "Unable to open the extension developer guide", + "removeExtensionTitle": "Uninstall extension?", + "removeExtensionConfirm": "Are you sure you want to uninstall “{name}”?", + "uninstall": "Uninstall", + "unableLoadExtensions": "Unable to load extensions", + "filters": "Filters", + "allTasks": "All Tasks", + "searchDownloads": "Search downloads…", + "searchHistory": "Search history…", + "addTask": "Add Task", + "emptyTaskList": "No tasks in this list", + "noMatchingTasks": "No matching tasks found", + "taskList": "Tasks", + "selectedCount": "{selected} / {total} selected", + "unableOpenPath": "Unable to open {path}", + "unableLocatePath": "Unable to locate {path}", + "clearSelection": "Clear selection", + "deselectTask": "Deselect", + "selectTask": "Select", + "updateManually": "Update manually", + "stopListening": "Stop listening", + "listenForUrl": "Listen for URL", + "waitingForReplacementUrl": "Waiting for a replacement URL", + "taskNotFound": "Task not found", + "storagePath": "Storage Path", + "directory": "Directory", + "mode": "Mode", + "keepOriginalName": "Keep original name", + "enterCount": "Enter count", + "chooseDownloadDirectory": "Choose download directory", + "applicationStorage": "App storage", + "applicationStorageDescription": "Reliable app storage; files are removed when the app is uninstalled", + "publicDownloadsDescription": "Keeps files after uninstall; availability depends on the Android device", + "downloadsDirectoryUnavailable": "Gopeed cannot write to Download/Gopeed on this device", + "enterValue": "Enter value", + "trackers": "Trackers", + "oneTrackerPerLine": "One tracker URL per line", + "pasteDownloadLinks": "Paste HTTP, HTTPS, or MAGNET links, or drop a torrent file here", + "unableReadTorrent": "Unable to read torrent file: {error}", + "torrentOnly": "Only .torrent files are supported", + "torrentDataEmpty": "Torrent file data is empty", + "torrentPathUnavailable": "Unable to access the torrent file path", + "selectAtLeastOneFile": "Select at least one file", + "resolvedFiles": "Resolved Files", + "resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}", + "resourceHasNoFiles": "The resource has no selectable file list", + "noFiles": "No files", + "createHistory": "Create History", + "noHistory": "No history", + "directDownloadDescription": "Skip resolving and create tasks immediately", + "taskProgress": "Task progress", + "updateAndResume": "Update and resume", + "httpHeaders": "HTTP headers", + "piece": "Piece {number}", + "pieceRange": "Pieces {start}–{end}", + "connectionStatus": "Connection Status", + "connectionCount": "{count, plural, =1{1 connection} other{{count} connections}}", + "retrying": "Retrying", + "connectionFailed": "Connection failed", + "connectionFailedRetries": "Connection failed after {count} retries", + "retryingCount": "Retrying · {count} retries", + "pieceStatus": "Piece Status", + "peers": "Peers", + "seeders": "Seeders", + "leechers": "Leechers", + "downloadedAmount": "Downloaded", + "uploadedAmount": "Uploaded", + "shareRatio": "Share Ratio", + "shareDuration": "Share Duration", + "connectedPeers": "{count} connected", + "active": "Active", + "completedPieces": "Completed Pieces", + "activePeers": "{count} active", + "incomplete": "Incomplete", + "address": "Address", + "client": "Client", + "pieces": "Pieces", + "source": "Source", + "fileRelevance": "File relevance", + "taskDetails": "Task Details", + "taskDetailsInfoTab": "Details", + "taskDetailsFilesTab": "Files", + "taskDetailsStatsTab": "Statistics", + "noStatistics": "No statistics available", + "noPeers": "No connections", + "deleteTasksTitle": "{count, plural, =1{Delete task?} other{Delete {count} tasks?}}", + "keepFilesDescription": "Only remove the task record from Gopeed", + "readingRuntimeStatus": "Reading runtime status…", + "apiConfigSaved": "API settings saved. Restart the app to apply them", + "socketPathRequired": "Socket path is required", + "serverRequired": "Server is required", + "portRangeError": "Enter a port between 0 and 65535", + "portInUseValue": "Port {port} is already in use", + "backgroundLocationPermissionRequired": "Always Allow location access is required for background downloads", + "scriptPath": "Script Path", + "logDirectoryUnavailable": "Log directory is not initialized", + "unableOpenExternalLink": "Unable to open the external link", + "neverUpdated": "Never updated", + "lastUpdated": "Last updated: {time}", + "unableLoadSettings": "Unable to load settings", + "fieldRequired": "{field} is required", + "addGithubMirror": "Add GitHub Mirror", + "editGithubMirror": "Edit GitHub Mirror", + "confirmDeleteTitle": "Confirm deletion?", + "confirmDeleteNamed": "Are you sure you want to delete “{name}”?", + "categoryFieldsRequired": "Category name and path are required", + "addCategory": "Add Category", + "editCategory": "Edit Category", + "deleteCategoryTitle": "Delete category?", + "updateFailed": "Update failed. Please try again later", + "newVersionAvailable": "A new version is available", + "releaseNotes": "What’s new", + "noReleaseNotes": "No release notes available", + "preparingDownload": "Preparing download…", + "booleanValueHint": "true / false", + "@runtimeInitializationFailed": { + "placeholders": { + "error": {} + } + }, + "@secondsRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@minutesRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@hoursRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@secondsDuration": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@minutesDuration": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@hoursDuration": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@currentVersion": { + "placeholders": { + "version": {} + } + }, + "@removeExtensionConfirm": { + "placeholders": { + "name": {} + } + }, + "@selectedCount": { + "placeholders": { + "selected": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "@unableOpenPath": { + "placeholders": { + "path": {} + } + }, + "@unableLocatePath": { + "placeholders": { + "path": {} + } + }, + "@unableReadTorrent": { + "placeholders": { + "error": {} + } + }, + "@resolvedFileCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@piece": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "@pieceRange": { + "placeholders": { + "start": { + "type": "int" + }, + "end": { + "type": "int" + } + } + }, + "@connectionCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@connectionFailedRetries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@retryingCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@connectedPeers": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@activePeers": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@deleteTasksTitle": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@portInUseValue": { + "placeholders": { + "port": { + "type": "int" + } + } + }, + "@lastUpdated": { + "placeholders": { + "time": {} + } + }, + "@fieldRequired": { + "placeholders": { + "field": {} + } + }, + "@confirmDeleteNamed": { + "placeholders": { + "name": {} + } + }, + "androidForegroundServiceChannel": "Gopeed Background Service", + "androidForegroundNotificationTitle": "Gopeed is running", + "androidForegroundNotificationText": "Downloads can continue in the background" +} diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb new file mode 100644 index 00000000..e00346bb --- /dev/null +++ b/ui/flutter/lib/l10n/app_es.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "es", + "label": "Español", + "error": "Error", + "tip": "Consejo", + "confirm": "Confirmar", + "confirmDelete": "¿Está seguro de que desea eliminar?", + "cancel": "Cancelar", + "on": "Activado", + "off": "Desactivado", + "selectAll": "Seleccionar Todo", + "select": "Seleccione", + "task": "Tareas", + "downloading": "descargando", + "downloaded": "descargado", + "setting": "Ajustes", + "donate": "Donar", + "exit": "Salir", + "pressBackAgainToExit": "Pulsa Atrás de nuevo para salir", + "create": "Crear Tarea", + "directDownload": "Descarga Directa", + "advancedOptions": "Opciones Avanzadas", + "followSettings": "Siga las opciones", + "downloadLink": "Enlace de Descarga", + "downloadLinkValid": "Por favor, introduce el enlace de descarga", + "downloadLinkHit": "Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append", + "downloadLinkHitDesktop": ", o arrastra el archivo torrent aquí directamente", + "download": "Descargar", + "noFileSelected": "Por favor, selecciona al menos un archivo para continuar", + "noStoragePermission": "Se requiere permiso de almacenamiento", + "selectFile": "Seleccionar Archivo", + "rename": "Renombrar", + "basic": "Básico", + "advanced": "Avanzado", + "general": "Generalidades", + "downloadDir": "Directorio de Descarga", + "rememberLastDownloadDir": "Guardar automáticamente el último directorio de tarea", + "downloadDirValid": "Por favor, selecciona el directorio de descarga", + "connections": "Conexiones", + "useServerCtime": "Usar tiempo del servidor para la creación de archivos", + "maxRunning": "Máximo de Tareas en Ejecución", + "defaultDirectDownload": "Ver descarga directa por defecto", + "autoStartTasks": "Iniciar automáticamente tareas incompletas al arrancar", + "autoTorrentEnable": "Crear automáticamente tareas BT desde archivos .torrent", + "autoTorrentDeleteAfterDownload": "Eliminar archivo .torrent después de crear tarea BT", + "autoDeleteMissingFileTasks": "Eliminar automáticamente tareas con archivos faltantes", + "items": "{count} elementos", + "subscribeTracker": "Suscribirse al Tracker", + "subscribeFail": "Suscripción fallida, por favor verifica la red o intenta más tarde", + "update": "Actualizar", + "updateDaily": "Actualizar diariamente", + "lastUpdate": "Última actualización: @time", + "addTracker": "Añadir Tracker", + "addTrackerHit": "Por favor, introduce la URL del servidor tracker, una por línea", + "ui": "Interfaz", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Oscuro", + "locale": "Idioma", + "notifyWhenNewVersion": "Notificar para actualizaciones", + "analyticsEnabled": "Subir estadísticas", + "analyticsEnabledDesc": "Compartir datos de uso anónimo para ayudarnos a mejorar", + "about": "Acerca de", + "homepage": "Página de inicio", + "version": "Versión", + "protocol": "Protocolo", + "port": "Puerto", + "apiToken": "Token API", + "notSet": "No establecido", + "set": "Establecido", + "portInUse": "El puerto [@port] está en uso, por favor cambia el puerto", + "effectAfterRestart": "Efectivo después de reiniciar", + "developer": "Desarrollador", + "logDirectory": "Directorio de Log", + "webhook": "Webhook", + "webhookEnable": "Activar Webhook", + "webhookDesc": "Enviar notificaciones HTTP POST cuando las tareas se completen o no", + "webhookUrlHint": "Introduzca URL de Webhook", + "webhookTest": "Prueba", + "webhookTestSuccess": "Prueba de webhook exitosa", + "webhookTestFail": "Falló la prueba del webhook", + "script": "Script", + "scriptEnable": "Script de habilitación", + "scriptDesc": "Ejecute scripts personalizados cuando las descargas se completen con éxito", + "scriptPathHint": "Ingrese la ruta del archivo del script (por ejemplo, /path/to/script.sh)", + "urlInvalid": "Por favor, introduzca una URL HTTP o HTTPS válida", + "required": "Este campo es necesario", + "show": "Mostrar", + "continueAction": "Continuar", + "pause": "Pausa", + "startAll": "Iniciar Todo", + "pauseAll": "Pausar Todo", + "deleteTask": "Eliminar @count tareas", + "deleteTaskTip": "Mantener archivos descargados", + "delete": "Eliminar", + "add": "Añadir", + "edit": "Editar", + "newVersionTitle": "Nueva versión @version disponible", + "newVersionUpdate": "Actualizar Ahora", + "newVersionLater": "Más tarde", + "extensions": "Extensiones", + "extensionInstallUrl": "URL de instalación", + "extensionInstallSuccess": "Instalado con éxito", + "extensionUpdateSuccess": "Actualizado con éxito", + "extensionDelete": "Eliminar Extensión", + "extensionAlreadyLatest": "Ya es la versión más reciente", + "extensionFind": "Buscar Extensiones", + "extensionDevelop": "Desarrollar Extensiones", + "extensionStoreTitle": "Extension Store", + "extensionStoreSection": "Extensiones de tiendas", + "extensionInstalledSection": "Extensiones instaladas", + "extensionInstalledEmpty": "No hay extensiones instaladas todavía", + "extensionStoreEmpty": "No se encontraron extensiones de coincidencia", + "extensionSearchHint": "Búsqueda por nombre, título, descripción o autor", + "extensionSortStars": "La mayoría de las estrellas", + "extensionSortInstalls": "La mayoría de los instalados", + "extensionSortUpdated": "Actualizado recientemente", + "extensionSortToggle": "Dirigir dirección", + "extensionLoadMore": "Carga más", + "extensionNoMore": "No más extensiones", + "extensionInstalled": "Instalado", + "extensionCanUpdate": "Actualización disponible", + "extensionInstall": "Instala", + "extensionLoadLocal": "Instala Carpeta Local", + "extensionFilterAll": "Todos", + "extensionFilterMarket": "Mercado", + "extensionFilterInstalled": "Instalado", + "extensionManualInstall": "Instalación manual", + "extensionInstallTools": "Instalar opciones", + "history": "Historial", + "clearHistory": "Borrar Historial", + "noHistoryFound": "No se encontró historial", + "serviceTitle": "Servicio de Descarga", + "serviceText": "En ejecución", + "network": "Red", + "proxy": "Protocolo proxy", + "noProxy": "Sin Proxy", + "systemProxy": "Proxy del Sistema", + "customProxy": "Proxy Personalizado", + "server": "Servidor", + "username": "Nombre de usuario", + "password": "Contraseña", + "thanks": "Agradecimientos", + "thanksDesc": "¡Gracias a todos los contribuyentes que han ayudado a construir y desarrollar la comunidad Gopeed!", + "browserExtension": "Extensión del Navegador", + "launchAtStartup": "Iniciar al Arranque", + "runAsMenubarApp": "Ejecutar como aplicación de barra de menú", + "runAsMenubarAppDesc": "Ocultar icono del Dock y ejecutar solo en la barra de menú", + "seedConfig": "Configuración de Semilla", + "seedKeep": "Mantener sembrado", + "seedRatio": "Ratio de semilla", + "seedTime": "Tiempo de semilla (minutos)", + "setAsDefaultBtClient": "Establecer como el cliente BT predeterminado", + "taskDetail": "Detalles de la Tarea", + "taskName": "Nombre de la Tarea", + "taskUrl": "URL de la Tarea", + "downloadPath": "Ruta de Descarga", + "skipVerifyCert": "Omitir Verificación de Certificado", + "archives": "Archivos", + "autoExtract": "Extraer archivos automáticamente", + "archivePassword": "Contraseña del archivo", + "archivePasswordHint": "Dejar vacío si no tiene contraseña", + "deleteAfterExtract": "Eliminar archivo después de extraer", + "extracting": "Extrayendo", + "extractDone": "Extracción completada", + "extractError": "Error en la extracción", + "waitingParts": "Esperando partes", + "name": "Nombre", + "size": "Tamaño", + "unknown": "Desconocido", + "fileSelectedCount": "Archivos:", + "fileSelectedSize": "Tamaño:", + "httpHeader": "Header", + "httpHeaderName": "Nombre del encabezado", + "httpHeaderValue": "Valor de la cabeza", + "login": "Iniciar sesión", + "username_required": "Por favor, introduzca su nombre de usuario", + "password_required": "Por favor, introduzca su contraseña", + "login_success": "Iniciar sesión con éxito", + "login_failed": "Login fallado, por favor, compruebe su nombre de usuario y contraseña", + "login_failed_network": "Login fallado, por favor, compruebe su conexión de red", + "insertPlaceholder": "Insertar marcador de posición", + "placeholderYear": "Año actual", + "placeholderMonth": "Mes actual (01-12)", + "placeholderDay": "Día actual (01-31)", + "placeholderDate": "Fecha completa (AAAA-MM-DD)", + "example": "ej. @value", + "downloadCategories": "Categorías de descarga", + "categoryMusic": "Música", + "categoryVideo": "Video", + "categoryDocument": "Documento", + "categoryProgram": "Programa", + "categoryName": "Nombre de categoría", + "categoryPath": "Ruta de categoría", + "builtInCategory": "La categoría incorporada no se puede eliminar", + "selectCategory": "Seleccionar categoría", + "quickDirectories": "Carpetas rápidas", + "githubMirror": "GitHub Mirror", + "githubMirrorEnable": "Activar el espejo GitHub", + "githubMirrorDesc": "Utilice espejos para acelerar descargas de contenido de GitHub", + "githubMirrorType": "Tipo de espejo", + "githubMirrorUrl": "URL del espejo", + "githubMirrorUrlHint": "Entrar URL del espejo", + "updateUrl": "Actualizar URL", + "updateUrlManual": "Actualización manual", + "updateUrlListen": "Escuchad, actualización", + "updateUrlListeningTip": "Esperando la actualización de URL", + "updateUrlCancelListen": "Cancelar", + "updateUrlDialogHint": "Introduzca nueva URL de descarga", + "pendingUpdateFound": "Descubrimiento de la tarea de actualización", + "pendingUpdateConfirm": "Task \"{name}\" está esperando la actualización de URL. ¿Quieres actualizarla con la nueva URL?", + "pendingUpdateYes": "Actualizar la tarea", + "pendingUpdateNo": "Crear una nueva tarea", + "ed2k": "ED2K", + "ed2kTcpPort": "Puerto de escucha UDP", + "ed2kUdpPort": "Direcciones del servidor", + "ed2kServerList": "Host", + "ed2kServerMet": ".met", + "ed2kNodesDat": "nodos.dat", + "ed2kAutoPort": "Auto asigna", + "ed2kOnePerLine": "Una entrada por línea", + "ed2kServersHint": ": puerto, uno por línea", + "ed2kServerMetHint": "URL Server.met o enlace de lista de servidores ed2k, uno por línea", + "ed2kNodesDatHint": ", una por línea", + "desktopNotification": "Notificaciones de escritorio", + "backgroundLocationKeepAlive": "Descargar Antecedentes Mantener Alive", + "notificationTaskDone": "Tarea completada", + "notificationTaskError": "Error de tarea", + "appTitle": "Gestor de descargas de Gopeed", + "unsupportedWindowPayload": "Solicitud de ventana no soportada", + "runtimeInitializationFailed": "No se puede inicializar el tiempo de ejecución de Gopeed. \n {error}", + "save": "Guardar", + "retry": "Retrocedimiento", + "close": "Cerrar", + "back": "Volver", + "open": "Abrir", + "openFile": "Abrir archivo", + "share": "Compartir", + "copy": "Copiar", + "copied": "Copiado", + "refresh": "Actualizar", + "status": "Situación", + "createdAt": "Fecha de creación", + "speed": "Velocidad", + "uploadSpeed": "Velocidad de carga", + "remaining": "Permaneciendo", + "downloadDuration": "Duración de la descarga", + "progress": "Progresos", + "failed": "Failed", + "completed": "Completado", + "waiting": "Esperando", + "taskFailed": "La tarea falló", + "unknownSize": "Tamaño desconocido", + "secondsRemaining": "{count} segundos restantes", + "minutesRemaining": "{count} minutos restantes", + "hoursRemaining": "{count} horas restantes", + "secondsDuration": "{count} segundos", + "minutesDuration": "{count} minutos", + "hoursDuration": "{count} horas", + "followSystem": "Sistema de seguimiento", + "basicSettings": "Ajustes básicos", + "downloadSettings": "Configuración de descargas", + "advancedSettings": "Ajustes avanzados", + "themeColor": "Color de tema", + "themeColorDescription": "Personalizar el color de acento de la aplicación", + "browserExtensionDescription": "Enviar descargas del navegador directamente a Gopeed después de la instalación", + "desktopNotificationDescription": "Mostrar una notificación del sistema cuando una tarea completa o falla", + "backgroundLocationKeepAliveDescription": "Utilice el acceso de ubicación para mantener las descargas activas en el fondo", + "currentVersion": "Versión actual {version}", + "contributors": "Contribuyentes", + "viewContributors": "Ver colaboradores", + "maxRunningDescription": "Número máximo de tareas que pueden descargar al mismo tiempo", + "defaultUserAgentDescription": "Agente de usuario predeterminado para descargas HTTP y HTTPS", + "listenPort": "Escucha Port", + "onePerLine": "Una entrada por línea", + "proxyProtocol": "Protocolo Proxy", + "restartRequired": "Este ajuste tiene efecto después de reiniciar la aplicación", + "socketPath": "Sendero del Socket", + "listenAddress": "Escucha", + "currentConnection": "Conexión actual", + "webhookPush": "Webhook Endpoints", + "webhookPushDescription": "Enviar notificaciones HTTP POST a estos puntos finales cuando las tareas se completen o no", + "scriptExecution": "Scripts", + "scriptExecutionDescription": "Ejecute estos scripts después de que una descarga complete con éxito", + "addWebhook": "Añadir Webhook", + "editWebhook": "Editar Webhook", + "addScript": "Agregar Script", + "editScript": "Editar script", + "openDirectory": "Abrir directorio", + "browseFiles": "Explorar archivos", + "updateEveryDay": "Actualización diaria", + "updateVersion": "Actualización", + "checkForUpdates": "Consulta para Actualizaciones", + "updating": "Actualizando...", + "colorGreen": "Verde", + "colorRed": "Rojo", + "colorRose": "Rose", + "colorOrange": "Naranja", + "colorCyan": "Cyan", + "colorBlue": "Azul", + "colorAmber": "Amber", + "colorPurple": "Prótelo", + "noExtensions": "No extensions", + "searchExtensions": "Extensiones de búsqueda...", + "extensionDeveloperGuide": "Guía de desarrollo de la extensión", + "extensionInstallFromUrl": "Instalar la extensión desde URL", + "unableOpenExtensionDocs": "No se puede abrir la guía de desarrollador de extensión", + "removeExtensionTitle": "¿Extensión de desinstalación?", + "removeExtensionConfirm": "¿Está seguro de que desea desinstalar “{name}”?", + "uninstall": "Desinstalación", + "unableLoadExtensions": "No se puede cargar extensiones", + "filters": "Filtros", + "allTasks": "Todas las tareas", + "searchDownloads": "Búsqueda descargas...", + "searchHistory": "Buscar en el historial…", + "addTask": "Agregar la tarea", + "emptyTaskList": "No hay tareas en esta lista", + "noMatchingTasks": "No se encontraron tareas de coincidencia", + "taskList": "Lista de tareas", + "selectedCount": "{selected} / {total} seleccionado", + "unableOpenPath": "No se puede abrir {path}", + "unableLocatePath": "No se puede localizar {path}", + "clearSelection": "Selección clara", + "deselectTask": "Deseleccionar", + "selectTask": "Seleccionar", + "updateManually": "Actualización manual", + "stopListening": "Deja de escuchar", + "listenForUrl": "Escucha URL", + "waitingForReplacementUrl": "Esperando una URL de reemplazo", + "taskNotFound": "La tarea no se encontró", + "storagePath": "Sendero de almacenamiento", + "directory": "Directorio", + "mode": "Modo", + "keepOriginalName": "Mantener el nombre original", + "enterCount": "Entrar", + "chooseDownloadDirectory": "Elija directorio de descarga", + "applicationStorage": "Almacenamiento de la aplicación", + "applicationStorageDescription": "Almacenamiento fiable de la aplicación; los archivos se eliminan al desinstalarla", + "publicDownloadsDescription": "Conserva los archivos tras la desinstalación; la disponibilidad depende del dispositivo Android", + "downloadsDirectoryUnavailable": "Gopeed no puede escribir en Download/Gopeed en este dispositivo", + "enterValue": "Valor de entrada", + "trackers": "Rastreadores", + "oneTrackerPerLine": "Una URL de rastreador por línea", + "pasteDownloadLinks": "Paste HTTP, HTTPS o enlaces MAGNET, o suelta un archivo torrent aquí", + "unableReadTorrent": "No se puede leer el archivo torrent: {error}", + "torrentOnly": "Sólo los archivos .torrent son compatibles", + "torrentDataEmpty": "Los datos de archivos Torrent están vacíos", + "torrentPathUnavailable": "Incapaz de acceder a la ruta del archivo torrent", + "selectAtLeastOneFile": "Seleccione al menos un archivo", + "resolvedFiles": "Archivos resueltos", + "resolvedFileCount": "{count} resueltos", + "resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable", + "noFiles": "No hay archivos", + "createHistory": "Crear historia", + "noHistory": "No hay historia", + "directDownloadDescription": "Saltar a resolver y crear tareas inmediatamente", + "taskProgress": "Progresos en la tarea", + "updateAndResume": "Actualización y reanudación de la actividad", + "httpHeaders": "Cabeceras HTTP", + "piece": "Pieza {number}", + "pieceRange": "Piezas {start}–{end}", + "connectionStatus": "Estado de conexión", + "connectionCount": "{count}", + "retrying": "Retiro", + "connectionFailed": "La conexión falló", + "connectionFailedRetries": "La conexión falló después de los reintentos de {count}", + "retryingCount": "Reintentando · {count} reintentos", + "pieceStatus": "Estado de la pieza", + "peers": "Pares", + "seeders": "Sembradores", + "leechers": "Clientes incompletos", + "downloadedAmount": "Descargado", + "uploadedAmount": "Subido", + "shareRatio": "Proporción de participación", + "shareDuration": "Duración de compartición", + "connectedPeers": "{count} conectado", + "active": "Activo", + "completedPieces": "Piezas completadas", + "activePeers": "{count} activo", + "incomplete": "Incompleto", + "address": "Dirección", + "client": "Cliente", + "pieces": "Piezas", + "source": "Fuente", + "fileRelevance": "Relevancia de archivos", + "taskDetails": "Detalles de la tarea", + "taskDetailsInfoTab": "Detalles", + "taskDetailsFilesTab": "Archivos", + "taskDetailsStatsTab": "Estadísticas", + "noStatistics": "No hay estadísticas disponibles", + "noPeers": "Sin conexiones", + "deleteTasksTitle": "¿Eliminar tareas {count}?", + "keepFilesDescription": "Eliminar únicamente el registro de tarea de Gopeed", + "readingRuntimeStatus": "Leyendo el estado de la carrera...", + "apiConfigSaved": "Ajustes de API guardados. reiniciar la aplicación para aplicarlos", + "socketPathRequired": "Se requiere un camino de bolsillo", + "serverRequired": "Se requiere servidor", + "portRangeError": "Introduzca un puerto entre 0 y 65535", + "portInUseValue": "El puerto {port} ya está en uso", + "backgroundLocationPermissionRequired": "Siempre Permite que el acceso a la ubicación sea necesario para las descargas de fondos", + "scriptPath": "Camino del script", + "logDirectoryUnavailable": "El directorio de registro no se inicializa", + "unableOpenExternalLink": "Incapaz de abrir el enlace externo", + "neverUpdated": "Nunca actualizado", + "lastUpdated": "Última actualización: {time}", + "unableLoadSettings": "No se puede cargar la configuración", + "fieldRequired": "{field}", + "addGithubMirror": "Añadir GitHub Mirror", + "editGithubMirror": "Editar GitHub Mirror", + "confirmDeleteTitle": "¿Confirmar la supresión?", + "confirmDeleteNamed": "¿Está seguro de que desea eliminar “{name}”?", + "categoryFieldsRequired": "Nombre de la categoría y ruta son necesarios", + "addCategory": "Añadir categoría", + "editCategory": "Editar la categoría", + "deleteCategoryTitle": "¿Supir la categoría?", + "updateFailed": "Actualizar falló. Por favor, intente de nuevo más tarde", + "newVersionAvailable": "Una nueva versión está disponible", + "releaseNotes": "Qué es nuevo", + "noReleaseNotes": "No hay notas de liberación disponibles", + "preparingDownload": "Preparando la descarga...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Servicio en segundo plano de Gopeed", + "androidForegroundNotificationTitle": "Gopeed se está ejecutando", + "androidForegroundNotificationText": "Las descargas pueden continuar en segundo plano" +} diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb new file mode 100644 index 00000000..0a5b2e74 --- /dev/null +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "fa", + "label": "فارسی", + "error": "اررور", + "tip": "نکته", + "confirm": "تایید", + "confirmDelete": "آیا مطمئن هستید که می خواهید حذف کنید؟", + "cancel": "انصراف", + "on": "روشن", + "off": "خاموش", + "selectAll": "انتخاب همه", + "select": "انتخاب", + "task": "کار", + "downloading": "دانلود کردن", + "downloaded": "دانلود شدن", + "setting": "تنظیمات", + "donate": "کمک مالی", + "exit": "خروج", + "pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید", + "create": "ایجاد کار", + "directDownload": "دانلود مستقیم", + "advancedOptions": "گزینه های پیشرفته", + "followSettings": "تنظیمات را دنبال کنید", + "downloadLink": "لینک دانلود", + "downloadLinkValid": "لطفا لینک دانلود را وارد کنید", + "downloadLinkHit": "لطفا لینک دانلود را وارد کنید، یک در هر خط @append", + "downloadLinkHitDesktop": "یا فایل تورنت را به طور مستقیم در اینجا بکشید", + "download": "دانلود", + "noFileSelected": "لطفا حداقل یک فایل را جهت دانلود انتخاب کنید", + "noStoragePermission": "مجوز ذخیره سازی مورد نیاز است", + "selectFile": "انتخاب فایل", + "rename": "تغییر نام", + "basic": "پایه", + "advanced": "پیشرفته", + "general": "عمومی", + "downloadDir": "دایرکتوری دانلود", + "rememberLastDownloadDir": "ذخیره خودکار پوشه آخرین وظیفه", + "downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید", + "connections": "اتصالات", + "useServerCtime": "استفاده از زمان سرور برای ایجاد فایل", + "maxRunning": "حداکثر وظایف در حال اجرا", + "defaultDirectDownload": "دانلود مستقیم را به صورت پیش فرض بررسی کنید", + "autoStartTasks": "شروع کارهای ناقص در استارت آپ", + "autoTorrentEnable": "ایجاد خودکار وظایف BT از فایل‌های .torrent", + "autoTorrentDeleteAfterDownload": "حذف فایل .torrent پس از ایجاد وظیفه BT", + "autoDeleteMissingFileTasks": "حذف خودکار وظایف با فایل‌های گم شده", + "items": "{count} آیتم", + "subscribeTracker": "پشتیبانی مشترک", + "subscribeFail": "شکست خورده، لطفا شبکه را چک کنید یا دوباره امتحان کنید", + "update": "بروزرسانی", + "updateDaily": "بروزرسانی روزانه", + "lastUpdate": "آخرین بروزرسانی: @time", + "addTracker": "ردیاب را اضافه کنید", + "addTrackerHit": "لطفا URL سرور ردیاب را وارد کنید، یک در هر خط", + "ui": "رابط کاربری", + "theme": "پوسته", + "themeSystem": "سیستم", + "themeLight": "روشن", + "themeDark": "تاریک", + "locale": "زبان", + "notifyWhenNewVersion": "عدم تایید برای به روز رسانی", + "analyticsEnabled": "آپلود آمار", + "analyticsEnabledDesc": "اشتراک گذاری داده های استفاده ناشناس برای کمک به بهبود ما", + "about": "درباره ی", + "homepage": "صفحه اصلی", + "version": "نسخه", + "protocol": "پروتکل", + "port": "پورت", + "apiToken": "API توکن", + "notSet": "NS", + "set": "مشخصات", + "portInUse": "پورت (@port) در حال استفاده است، لطفا پورت را تغییر دهید", + "effectAfterRestart": "اثر پس از شروع دوباره", + "developer": "توسعه دهنده توسعه دهنده", + "logDirectory": "پوشه گزارش‌ها", + "webhook": "Webhook", + "webhookEnable": "فعال‌سازی Webhook", + "webhookDesc": "ارسال اعلان HTTP POST هنگام تکمیل یا شکست وظایف", + "webhookUrlHint": "نشانی URL وب‌هوک را وارد کنید", + "webhookTest": "آزمایش", + "webhookTestSuccess": "Webhook با موفقیت انجام شد", + "webhookTestFail": "Webhook ناموفق بود", + "script": "اسکریپت", + "scriptEnable": "فعال‌سازی اسکریپت", + "scriptDesc": "اجرای اسکریپت های سفارشی زمانی که دانلود کامل", + "scriptPathHint": "مسیر فایل اسکریپت (به عنوان مثال، /path / to /script.sh)", + "urlInvalid": "لطفاً یک URL معتبر HTTP یا HTTPS وارد کنید", + "required": "این زمینه مورد نیاز است", + "show": "نمایش", + "continueAction": "ادامه", + "pause": "مکث", + "startAll": "شروع همه", + "pauseAll": "توقف همه", + "deleteTask": "حذف @count کار", + "deleteTaskTip": "فایل های دانلود شده را نگه دارد", + "delete": "پاک کردن", + "add": "افزودن", + "edit": "ویرایش", + "newVersionTitle": "@version عنوان: کشف نسخه جدید", + "newVersionUpdate": "بروزرسانی", + "newVersionLater": "بعداً", + "extensions": "افزونه‌ها", + "extensionInstallUrl": "نشانی نصب", + "extensionInstallSuccess": "نصب شده با موفقیت", + "extensionUpdateSuccess": "به روز رسانی موفقیت آمیز", + "extensionDelete": "حذف افزونه", + "extensionAlreadyLatest": "این نسخه در حال حاضر آخرین نسخه است", + "extensionFind": "یافتن افزونه ها", + "extensionDevelop": "توسعه افزونه های", + "extensionStoreTitle": "فروشگاه افزونه‌ها", + "extensionStoreSection": "افزونه‌های فروشگاه", + "extensionInstalledSection": "نصب افزونه های نصب شده", + "extensionInstalledEmpty": "هیچ افزونه ای نصب نشده است", + "extensionStoreEmpty": "هیچ افزونه ی تطبیق یافته ی", + "extensionSearchHint": "جستجو با نام، عنوان، شرح یا نویسنده", + "extensionSortStars": "بیشتر ستاره ها", + "extensionSortInstalls": "اکثر نصب ها", + "extensionSortUpdated": "به تازگی به روز رسانی", + "extensionSortToggle": "جهت یابی را مشخص کنید", + "extensionLoadMore": "بار بیشتر", + "extensionNoMore": "افزونه های دیگر", + "extensionInstalled": "نصب شده", + "extensionCanUpdate": "به روز رسانی در دسترس", + "extensionInstall": "نصب", + "extensionLoadLocal": "نصب از پوشه محلی", + "extensionFilterAll": "همه چیز", + "extensionFilterMarket": "بازار", + "extensionFilterInstalled": "نصب شده", + "extensionManualInstall": "نصب دستی", + "extensionInstallTools": "گزینه های نصب", + "history": "تاریخچه", + "clearHistory": "تاریخ روشن", + "noHistoryFound": "هیچ تاریخی پیدا نشد", + "serviceTitle": "خدمات", + "serviceText": "دویدن", + "network": "شبکه", + "proxy": "پروکسی", + "noProxy": "هیچ پروکسی", + "systemProxy": "پروکسی سیستم", + "customProxy": "پروکسی سفارشی", + "server": "سرور", + "username": "نام کاربری", + "password": "گذرواژه", + "thanks": "تشکر", + "thanksDesc": "از تمامی افرادی که در ساخت و توسعه‌ی جامعه‌ی Gopeed مشارکت کرده‌اند، سپاسگزاری می‌کنیم!", + "browserExtension": "افزونه مرورگر", + "launchAtStartup": "راه‌اندازی در استارت‌آپ", + "runAsMenubarApp": "اجرا به عنوان برنامه نوار منو", + "runAsMenubarAppDesc": "پنهان کردن آیکون Dock و اجرا فقط در نوار منو", + "seedConfig": "● کنفدراسیون", + "seedKeep": "بذر را نگه دارید تا زمانی که به صورت دستی متوقف شود", + "seedRatio": "نسبت دید", + "seedTime": "زمان ( دقیقه)", + "setAsDefaultBtClient": "تنظیم به‌عنوان کلاینت پیش‌فرض BT", + "taskDetail": "جزئیات وظیفه", + "taskName": "نام وظیفه", + "taskUrl": "URL وظیفه", + "downloadPath": "مسیر دانلود", + "skipVerifyCert": "رد کردن تأیید گواهینامه", + "archives": "آرشیوها", + "autoExtract": "استخراج خودکار بایگانی‌ها", + "archivePassword": "رمز عبور بایگانی", + "archivePasswordHint": "اگر رمز ندارد خالی بگذارید", + "deleteAfterExtract": "حذف آرشیو پس از استخراج", + "extracting": "در حال استخراج", + "extractDone": "استخراج کامل شد", + "extractError": "استخراج ناموفق بود", + "waitingParts": "در انتظار قسمت‌ها", + "name": "نام", + "size": "اندازه", + "unknown": "ناشناخته", + "fileSelectedCount": "فایل ها:", + "fileSelectedSize": "اندازه:", + "httpHeader": "سربرگ HTTP", + "httpHeaderName": "نام سربرگ", + "httpHeaderValue": "ارزش های سرسر", + "login": "ورود", + "username_required": "لطفا نام کاربری خود را وارد کنید", + "password_required": "لطفاً رمز عبور خود را وارد کنید", + "login_success": "موفقیت آمیز ورود", + "login_failed": "ورود شکست خورد، لطفا نام کاربری و رمز عبور خود را چک کنید", + "login_failed_network": "ورود شکست خورد، لطفا اتصال شبکه خود را چک کنید", + "insertPlaceholder": "درج نگهدارنده", + "placeholderYear": "سال جاری", + "placeholderMonth": "ماه جاری (01-12)", + "placeholderDay": "روز جاری (01-31)", + "placeholderDate": "تاریخ کامل (YYYY-MM-DD)", + "example": "مثلا @value", + "downloadCategories": "دسته‌بندی‌های دانلود", + "categoryMusic": "موسیقی", + "categoryVideo": "ویدیو", + "categoryDocument": "سند", + "categoryProgram": "برنامه", + "categoryName": "نام دسته‌بندی", + "categoryPath": "مسیر دسته‌بندی", + "builtInCategory": "دسته‌بندی داخلی قابل حذف نیست", + "selectCategory": "انتخاب دسته‌بندی", + "quickDirectories": "پوشه‌های سریع", + "githubMirror": "GitHub آینه", + "githubMirrorEnable": "قابلیت های GitHub Mirror", + "githubMirrorDesc": "استفاده از آینه برای سرعت بخشیدن به دانلود محتوای GitHub", + "githubMirrorType": "آینه", + "githubMirrorUrl": "URL آینه", + "githubMirrorUrlHint": "URL آینه را وارد کنید", + "updateUrl": "URL به‌روزرسانی", + "updateUrlManual": "به‌روزرسانی دستی URL", + "updateUrlListen": "به روز رسانی گوش دهید", + "updateUrlListeningTip": "در انتظار به‌روزرسانی URL", + "updateUrlCancelListen": "لغو گوش", + "updateUrlDialogHint": "URL دانلود جدید", + "pendingUpdateFound": "وظیفه‌ای در انتظار به‌روزرسانی پیدا شد", + "pendingUpdateConfirm": "Task \"{name}\" منتظر به روز رسانی URL است، آیا می خواهید آن را با URL جدید به روز کنید؟", + "pendingUpdateYes": "به‌روزرسانی وظیفه", + "pendingUpdateNo": "ایجاد وظیفه جدید", + "ed2k": "ED2K", + "ed2kTcpPort": "درگاه شنود TCP", + "ed2kUdpPort": "درگاه شنود UDP", + "ed2kServerList": "میزبان", + "ed2kServerMet": "Server.met", + "ed2kNodesDat": "nodes.dat منابع", + "ed2kAutoPort": "خودکارسازی خودکار", + "ed2kOnePerLine": "یک ورودی در هر خط", + "ed2kServersHint": ": پورت، یک در هر خط", + "ed2kServerMetHint": "Server.met یا لینک لیست سرور ed2k، یکی در هر خط", + "ed2kNodesDatHint": "، یکی در هر خط", + "desktopNotification": "اعلان‌های دسکتاپ", + "backgroundLocationKeepAlive": "فعال نگه‌داشتن در پس‌زمینه", + "notificationTaskDone": "وظیفه تکمیل شد", + "notificationTaskError": "خطای وظیفه", + "appTitle": "مدیر دانلود Gopeed", + "unsupportedWindowPayload": "درخواست پنجره پشتیبانی نشده", + "runtimeInitializationFailed": "نمی‌توان زمان اجرای Gopeed را مقداردهی کرد. \n {error}", + "save": "ذخیره", + "retry": "تلاش مجدد", + "close": "بستن", + "back": "بازگشت", + "open": "باز کردن", + "openFile": "باز کردن فایل", + "share": "اشتراک‌گذاری", + "copy": "کپی", + "copied": "اخراج", + "refresh": "تازه‌سازی", + "status": "وضعیت", + "createdAt": "زمان ایجاد", + "speed": "سرعت", + "uploadSpeed": "سرعت آپلود", + "remaining": "باقی مانده", + "downloadDuration": "مدت دانلود", + "progress": "پیشرفت", + "failed": "شکست خورده", + "completed": "تکمیل شده", + "waiting": "انتظار", + "taskFailed": "شکست در کار", + "unknownSize": "اندازه ناشناخته", + "secondsRemaining": "{count} ثانیه باقی‌مانده", + "minutesRemaining": "{count} دقیقه باقی‌مانده", + "hoursRemaining": "{count} ساعت باقی‌مانده", + "secondsDuration": "{count} ثانیه", + "minutesDuration": "{count} دقیقه", + "hoursDuration": "{count} ساعت", + "followSystem": "پیروی از سیستم", + "basicSettings": "تنظیمات پایه", + "downloadSettings": "تنظیمات دانلود", + "advancedSettings": "تنظیمات پیشرفته", + "themeColor": "رنگ پوسته", + "themeColorDescription": "سفارشی کردن رنگ برنامه", + "browserExtensionDescription": "دانلود مستقیم مرورگر را به Gopeed ارسال کنید پس از نصب", + "desktopNotificationDescription": "یک اعلان سیستم را زمانی که یک کار کامل یا شکست می خورد نشان دهید", + "backgroundLocationKeepAliveDescription": "استفاده از دسترسی به مکان برای نگه داشتن بارگیری فعال در پس زمینه", + "currentVersion": "نسخه فعلی {version}", + "contributors": "مشارکت کنندگان", + "viewContributors": "مشاهده کنندگان", + "maxRunningDescription": "حداکثر تعداد کارهایی که ممکن است در همان زمان بارگیری شوند", + "defaultUserAgentDescription": "پیش‌فرض User-Agent برای دانلودهای HTTP و HTTPS", + "listenPort": "گوش دادن به پورت", + "onePerLine": "یک ورودی در هر خط", + "proxyProtocol": "پروتکل پروکسی", + "restartRequired": "این تنظیمات پس از شروع مجدد برنامه، اثر می گیرد", + "socketPath": "مسیر سوکت", + "listenAddress": "آدرس گوش دادن", + "currentConnection": "اتصال فعلی", + "webhookPush": "نقاط پایانی وب‌هوک", + "webhookPushDescription": "ارسال اعلان‌های HTTP POST به این نقاط هنگام تکمیل یا شکست وظایف", + "scriptExecution": "اسکریپت‌ها", + "scriptExecutionDescription": "اجرای این اسکریپت ها پس از اتمام یک دانلود موفق", + "addWebhook": "افزودن وب‌هوک", + "editWebhook": "ویرایش وب‌هوک", + "addScript": "افزودن اسکریپت", + "editScript": "ویرایش اسکریپت", + "openDirectory": "باز کردن پوشه", + "browseFiles": "مرور فایل‌ها", + "updateEveryDay": "به روز رسانی روزانه", + "updateVersion": "به‌روزرسانی نسخه", + "checkForUpdates": "بررسی به‌روزرسانی‌ها", + "updating": "...", + "colorGreen": "سبز", + "colorRed": "قرمز", + "colorRose": "رز", + "colorOrange": "نارنجی", + "colorCyan": "فیروزه‌ای", + "colorBlue": "آبی", + "colorAmber": "کهربایی", + "colorPurple": "بنفش", + "noExtensions": "هیچ افزونه ای", + "searchExtensions": "افزونه های جستجو ...", + "extensionDeveloperGuide": "راهنمای توسعه دهنده", + "extensionInstallFromUrl": "نصب افزونه از URL", + "unableOpenExtensionDocs": "راهنمای توسعه افزونه باز نشد", + "removeExtensionTitle": "حذف افزونه", + "removeExtensionConfirm": "آیا مطمئن هستید که می خواهید \"{name}\" را حذف نصب کنید؟", + "uninstall": "حذف نصب", + "unableLoadExtensions": "افزونه‌ها بارگیری نشدند", + "filters": "فیلترها", + "allTasks": "همه وظایف", + "searchDownloads": "جستجوی دانلودها", + "searchHistory": "جستجو در تاریخچه…", + "addTask": "افزودن وظیفه", + "emptyTaskList": "هیچ وظیفه ای در این لیست", + "noMatchingTasks": "هیچ وظایف تطبیق یافته ای پیدا نشد", + "taskList": "لیست وظایف", + "selectedCount": "{selected} / {total} انتخاب شد", + "unableOpenPath": "قادر به باز کردن {path} نیست", + "unableLocatePath": "نمی تواند مکان {path} را پیدا کند", + "clearSelection": "انتخاب واضح", + "deselectTask": "لغو انتخاب", + "selectTask": "انتخاب", + "updateManually": "به روز رسانی دستی", + "stopListening": "گوش دادن را متوقف کنید", + "listenForUrl": "گوش‌دادن برای URL", + "waitingForReplacementUrl": "در انتظار URL جایگزین", + "taskNotFound": "وظایفی که پیدا نشد", + "storagePath": "مسیر ذخیره سازی", + "directory": "دایرکتوری", + "mode": "حالت", + "keepOriginalName": "نام اصلی را نگه دارید", + "enterCount": "ورود به حساب", + "chooseDownloadDirectory": "انتخاب پوشه دانلود", + "applicationStorage": "فضای ذخیره‌سازی برنامه", + "applicationStorageDescription": "فضای ذخیره‌سازی مطمئن برنامه؛ با حذف برنامه، فایل‌ها نیز حذف می‌شوند", + "publicDownloadsDescription": "فایل‌ها پس از حذف برنامه باقی می‌مانند؛ دسترسی به دستگاه Android بستگی دارد", + "downloadsDirectoryUnavailable": "Gopeed نمی‌تواند در این دستگاه در Download/Gopeed بنویسد", + "enterValue": "ارزش های وارد شده", + "trackers": "ردیاب ها", + "oneTrackerPerLine": "یک URL ردیاب در هر خط", + "pasteDownloadLinks": "HTTP، HTTPS یا لینک های MAGNET را در اینجا قرار دهید یا یک فایل تورنت را در اینجا رها کنید", + "unableReadTorrent": "قادر به خواندن فایل تورنت نیست: {error}", + "torrentOnly": "فقط فایل های پشتیبان پشتیبانی می شوند", + "torrentDataEmpty": "داده های فایل تورنت خالی است", + "torrentPathUnavailable": "امکان دسترسی به مسیر فایل تورنت", + "selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید", + "resolvedFiles": "فایل های حل شده", + "resolvedFileCount": "{count} فایل شناسایی شد", + "resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد", + "noFiles": "فایل ها", + "createHistory": "ایجاد تاریخ", + "noHistory": "تاریخ", + "directDownloadDescription": "پیاده سازی و ایجاد وظایف بلافاصله", + "taskProgress": "پیشرفت شغلی", + "updateAndResume": "به روز رسانی و رزومه", + "httpHeaders": "هدرهای HTTP", + "piece": "قطعه {number}", + "pieceRange": "قطعات {start} تا {end}", + "connectionStatus": "وضعیت اتصال", + "connectionCount": "{count} اتصال", + "retrying": "تمرین مجدد", + "connectionFailed": "عدم اتصال", + "connectionFailedRetries": "پس از تلاش مجدد {count} انجام نشد", + "retryingCount": "· تلاش مجدد {count}", + "pieceStatus": "وضعیت قطعات", + "peers": "همتاها", + "seeders": "سیدرها", + "leechers": "لیچرها", + "downloadedAmount": "دانلود بازی", + "uploadedAmount": "آپلود شده", + "shareRatio": "نسبت اشتراک‌گذاری", + "shareDuration": "مدت اشتراک‌گذاری", + "connectedPeers": "{count} متصل است", + "active": "فعال", + "completedPieces": "قطعات کامل", + "activePeers": "{count} فعال است", + "incomplete": "ناقص", + "address": "آدرس", + "client": "مشتری", + "pieces": "قطعات", + "source": "منبع", + "fileRelevance": "ارتباط فایل", + "taskDetails": "جزئیات وظیفه", + "taskDetailsInfoTab": "جزئیات", + "taskDetailsFilesTab": "فایل‌ها", + "taskDetailsStatsTab": "آمار", + "noStatistics": "هیچ آمار موجود در دسترس نیست", + "noPeers": "هیچ ارتباطی ندارد", + "deleteTasksTitle": "وظایف {count} حذف شود؟", + "keepFilesDescription": "فقط رکورد وظیفه را از Gopeed حذف کنید", + "readingRuntimeStatus": "خواندن وضعیت زمان اجرا ...", + "apiConfigSaved": "تنظیمات API ذخیره شده است. دوباره برنامه را برای اعمال آنها شروع کنید", + "socketPathRequired": "مسیر سوکت مورد نیاز است", + "serverRequired": "سرور مورد نیاز است", + "portRangeError": "یک پورت بین 0 تا 65۳۵ را وارد کنید", + "portInUseValue": "{port} در حال حاضر در حال استفاده است", + "backgroundLocationPermissionRequired": "همیشه اجازه دسترسی به مکان برای بارگیری پس زمینه لازم است", + "scriptPath": "دانلود بازی Path", + "logDirectoryUnavailable": "پوشه گزارش‌ها مقداردهی نشده است", + "unableOpenExternalLink": "غیر قابل انکار برای باز کردن لینک خارجی", + "neverUpdated": "هرگز به روز نشده", + "lastUpdated": "آخرین به روز رسانی: {time}", + "unableLoadSettings": "تنظیمات بارگیری نشدند", + "fieldRequired": "{field} الزامی است", + "addGithubMirror": "افزودن آینه GitHub", + "editGithubMirror": "ویرایش آینه GitHub", + "confirmDeleteTitle": "حذف تایید شده؟", + "confirmDeleteNamed": "آیا مطمئن هستید که می خواهید \"{name}\" را حذف کنید؟", + "categoryFieldsRequired": "نام و مسیر مورد نیاز است", + "addCategory": "افزودن دسته‌بندی", + "editCategory": "ویرایش دسته‌بندی", + "deleteCategoryTitle": "دسته بندی حذفی؟", + "updateFailed": "به روز رسانی شکست خورد لطفا دوباره امتحان کنید", + "newVersionAvailable": "نسخه جدید در دسترس است", + "releaseNotes": "چه چیز جدیدی است", + "noReleaseNotes": "هیچ یادداشت آزادی در دسترس نیست", + "preparingDownload": "آماده سازی دانلود ...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "سرویس پس‌زمینه Gopeed", + "androidForegroundNotificationTitle": "Gopeed در حال اجرا است", + "androidForegroundNotificationText": "دانلودها می‌توانند در پس‌زمینه ادامه پیدا کنند" +} diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb new file mode 100644 index 00000000..41853054 --- /dev/null +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "fr", + "label": "Français", + "error": "Erreur", + "tip": "Conseil", + "confirm": "Confirmer", + "confirmDelete": "Êtes-vous sûr de vouloir supprimer ?", + "cancel": "Annuler", + "on": "Activé", + "off": "Désactivé", + "selectAll": "Sélectionner tout", + "select": "Sélectionner", + "task": "Tâches", + "downloading": "Téléchargement", + "downloaded": "Téléchargé", + "setting": "Paramètres", + "donate": "Faire un don", + "exit": "Quitter", + "pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter", + "create": "Créer une tâche", + "directDownload": "Téléchargement direct", + "advancedOptions": "Options avancées", + "followSettings": "Suivre les paramètres", + "downloadLink": "Lien de téléchargement", + "downloadLinkValid": "Veuillez entrer le lien de téléchargement", + "downloadLinkHit": "Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge", + "downloadLinkHitDesktop": ", ou glissez directement le fichier torrent ici", + "download": "Télécharger", + "noFileSelected": "Veuillez sélectionner au moins un fichier pour continuer", + "noStoragePermission": "Permission de stockage requise", + "selectFile": "Sélectionner un fichier", + "rename": "Renommer", + "basic": "De base", + "advanced": "Avancé", + "general": "Général", + "downloadDir": "Répertoire de téléchargement", + "rememberLastDownloadDir": "Enregistrer automatiquement le dernier dossier de tâche", + "downloadDirValid": "Veuillez sélectionner le répertoire de téléchargement", + "connections": "Connexions", + "useServerCtime": "Utiliser l'heure du serveur pour la création de fichier", + "maxRunning": "Tâches en cours maximum", + "defaultDirectDownload": "Vérifiez le téléchargement direct par défaut", + "autoStartTasks": "Démarrer automatiquement les tâches incomplètes au lancement", + "autoTorrentEnable": "Créer automatiquement des tâches BT à partir de fichiers .torrent", + "autoTorrentDeleteAfterDownload": "Supprimer le fichier .torrent après création de tâche BT", + "autoDeleteMissingFileTasks": "Supprimer automatiquement les tâches avec fichiers manquants", + "items": "{count} éléments", + "subscribeTracker": "S'abonner au tracker", + "subscribeFail": "Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard", + "update": "Mettre à jour", + "updateDaily": "Mettre à jour quotidiennement", + "lastUpdate": "Dernière mise à jour : @time", + "addTracker": "Ajouter un tracker", + "addTrackerHit": "Veuillez entrer l'URL du serveur de tracker, une par ligne", + "ui": "Interface utilisateur", + "theme": "Thème", + "themeSystem": "Système", + "themeLight": "Clair", + "themeDark": "Sombre", + "locale": "Langue", + "notifyWhenNewVersion": "Avis pour les mises à jour", + "analyticsEnabled": "Téléchargement de statistiques", + "analyticsEnabledDesc": "Partager des données anonymes pour nous aider à améliorer", + "about": "À propos", + "homepage": "Page d'accueil", + "version": "Version", + "protocol": "Protocole", + "port": "Port", + "apiToken": "Jeton API", + "notSet": "Non défini", + "set": "Défini", + "portInUse": "Le port [@port] est utilisé, veuillez changer de port", + "effectAfterRestart": "Effectif après redémarrage", + "developer": "Développeur", + "logDirectory": "Répertoire des journaux", + "webhook": "Webhook", + "webhookEnable": "Activer Webhook", + "webhookDesc": "Envoyer des notifications HTTP POST lorsque les tâches sont terminées ou échouées", + "webhookUrlHint": "Saisissez l'URL de webhook", + "webhookTest": "Essai", + "webhookTestSuccess": "réussi", + "webhookTestFail": "Essai", + "script": "Script", + "scriptEnable": "Activer le script", + "scriptDesc": "Exécuter des scripts personnalisés lorsque les téléchargements se terminent avec succès", + "scriptPathHint": "Saisissez le chemin de fichier de script (par exemple, /path/to/script.sh)", + "urlInvalid": "Veuillez saisir une URL HTTP ou HTTPS valide", + "required": "Ce champ est obligatoire", + "show": "Afficher", + "continueAction": "Continuer", + "pause": "Pause", + "startAll": "Tout démarrer", + "pauseAll": "Tout suspendre", + "deleteTask": "Supprimer @count tâches", + "deleteTaskTip": "Conserver les fichiers téléchargés", + "delete": "Supprimer", + "add": "Ajouter", + "edit": "Modifier", + "newVersionTitle": "Nouvelle version @version disponible", + "newVersionUpdate": "Mettre à jour maintenant", + "newVersionLater": "Plus tard", + "extensions": "Extensions", + "extensionInstallUrl": "URL d'installation", + "extensionInstallSuccess": "Installé avec succès", + "extensionUpdateSuccess": "Mis à jour avec succès", + "extensionDelete": "Supprimer l'extension", + "extensionAlreadyLatest": "C'est déjà la dernière version", + "extensionFind": "Trouver des extensions", + "extensionDevelop": "Développer des extensions", + "extensionStoreTitle": "Magasin d'extension", + "extensionStoreSection": "Extensions de stockage", + "extensionInstalledSection": "Extensions installées", + "extensionInstalledEmpty": "Aucune extension n'a encore été installée", + "extensionStoreEmpty": "Aucune extension correspondante trouvée", + "extensionSearchHint": "Recherche par nom, titre, description ou auteur", + "extensionSortStars": "La plupart des étoiles", + "extensionSortInstalls": "La plupart des Installations", + "extensionSortUpdated": "Dernière mise à jour", + "extensionSortToggle": "Infléchir la direction du tri", + "extensionLoadMore": "Charger plus", + "extensionNoMore": "Plus d'extensions", + "extensionInstalled": "Installé", + "extensionCanUpdate": "Mise à jour disponible", + "extensionInstall": "Installer", + "extensionLoadLocal": "Installer le dossier local", + "extensionFilterAll": "Tous", + "extensionFilterMarket": "Marché", + "extensionFilterInstalled": "Installé", + "extensionManualInstall": "Installer le manuel", + "extensionInstallTools": "Installer les options", + "history": "Historique", + "clearHistory": "Effacer l'historique", + "noHistoryFound": "Aucun historique trouvé", + "serviceTitle": "Service de téléchargement", + "serviceText": "En cours d'exécution", + "network": "Réseau", + "proxy": "Proxy", + "noProxy": "Pas de proxy", + "systemProxy": "Proxy système", + "customProxy": "Proxy personnalisé", + "server": "Serveur", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "thanks": "Merci", + "thanksDesc": "Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !", + "browserExtension": "Extension du navigateur", + "launchAtStartup": "Lancer au démarrage", + "runAsMenubarApp": "Exécuter en tant qu'application de barre de menus", + "runAsMenubarAppDesc": "Masquer l'icône du Dock et exécuter uniquement dans la barre de menus", + "seedConfig": "Config de semences", + "seedKeep": "Continuer à semer jusqu'à ce qu'il soit arrêté manuellement", + "seedRatio": "Taux de semences", + "seedTime": "Temps de semis (minutes)", + "setAsDefaultBtClient": "Définir comme le client BT par défaut", + "taskDetail": "Détail de la tâche", + "taskName": "Nom de la tâche", + "taskUrl": "URL de la tâche", + "downloadPath": "Télécharger le chemin", + "skipVerifyCert": "Ignorer la vérification du certificat", + "archives": "Archives", + "autoExtract": "Extraire automatiquement les archives", + "archivePassword": "Mot de passe de l'archive", + "archivePasswordHint": "Laisser vide si non protégé par mot de passe", + "deleteAfterExtract": "Supprimer l'archive après extraction", + "extracting": "Extraction en cours", + "extractDone": "Extraction terminée", + "extractError": "Échec de l'extraction", + "waitingParts": "En attente des parties", + "name": "Nom", + "size": "Taille", + "unknown": "Inconnu", + "fileSelectedCount": "Fichiers & #160;:", + "fileSelectedSize": "Taille:", + "httpHeader": "En-tête", + "httpHeaderName": "En-tête", + "httpHeaderValue": "Valeur en-tête", + "login": "Connexion", + "username_required": "Veuillez saisir votre nom d'utilisateur", + "password_required": "Veuillez saisir votre mot de passe", + "login_success": "Connexion réussie", + "login_failed": "Login échoué, veuillez vérifier votre nom d'utilisateur et votre mot de passe", + "login_failed_network": "Connexion échouée, veuillez vérifier votre connexion réseau", + "insertPlaceholder": "Insérer un espace réservé", + "placeholderYear": "Année en cours", + "placeholderMonth": "Mois en cours (01-12)", + "placeholderDay": "Jour en cours (01-31)", + "placeholderDate": "Date complète (AAAA-MM-JJ)", + "example": "ex. @value", + "downloadCategories": "Catégories de téléchargement", + "categoryMusic": "Musique", + "categoryVideo": "Vidéo", + "categoryDocument": "Numéro", + "categoryProgram": "Programme", + "categoryName": "Nom de la catégorie", + "categoryPath": "Chemin de la catégorie", + "builtInCategory": "La catégorie intégrée ne peut pas être supprimée", + "selectCategory": "Sélectionner une catégorie", + "quickDirectories": "Dossiers rapides", + "githubMirror": "Miroir GitHub", + "githubMirrorEnable": "Activer le miroir GitHub", + "githubMirrorDesc": "Utiliser des miroirs pour accélérer les téléchargements de contenu GitHub", + "githubMirrorType": "Type de miroir", + "githubMirrorUrl": "URL du miroir", + "githubMirrorUrlHint": "Saisissez l'URL du miroir", + "updateUrl": "Mettre à jour l'URL", + "updateUrlManual": "Mise à jour manuelle", + "updateUrlListen": "Écouter la mise à jour", + "updateUrlListeningTip": "En attente de mise à jour de l'URL", + "updateUrlCancelListen": "Annuler Écouter", + "updateUrlDialogHint": "Saisissez une nouvelle URL de téléchargement", + "pendingUpdateFound": "Tâche de mise à jour en attente trouvée", + "pendingUpdateConfirm": "Tâche \"{name}\" est en attente de mise à jour URL. Voulez-vous la mettre à jour avec la nouvelle URL?", + "pendingUpdateYes": "Tâche de mise à jour", + "pendingUpdateNo": "Créer une nouvelle tâche", + "ed2k": "ED2K", + "ed2kTcpPort": "Port d'écoute UDP", + "ed2kUdpPort": "Adresses des serveurs", + "ed2kServerList": "Hôte", + "ed2kServerMet": ".met", + "ed2kNodesDat": "nodes.dat", + "ed2kAutoPort": "Attribuer automatiquement", + "ed2kOnePerLine": "Une entrée par ligne", + "ed2kServersHint": " : port, un par ligne", + "ed2kServerMetHint": "URL Server.met ou lien de liste de serveurs ed2k, un par ligne", + "ed2kNodesDatHint": "chemin nodes.dat ou URL, un par ligne", + "desktopNotification": "Notifications de bureau", + "backgroundLocationKeepAlive": "Télécharger le fond de l'écran Keep Alive", + "notificationTaskDone": "Tâche terminée", + "notificationTaskError": "Erreur de tâche", + "appTitle": "Gestionnaire de téléchargements Gopeed", + "unsupportedWindowPayload": "Demande de fenêtre non prise en charge", + "runtimeInitializationFailed": "Impossible d'initialiser le runtime Gopeed. \n {error}", + "save": "Enregistrer", + "retry": "Réessayer", + "close": "Fermer", + "back": "Retour", + "open": "Ouvrir", + "openFile": "Ouvrir le fichier", + "share": "Partager", + "copy": "Copier", + "copied": "Copied", + "refresh": "Rafraîchir", + "status": "État", + "createdAt": "Date de création", + "speed": "Vitesse", + "uploadSpeed": "Vitesse de chargement", + "remaining": "Reste", + "downloadDuration": "Durée du téléchargement", + "progress": "Progrès accomplis", + "failed": "Échec", + "completed": "Terminé", + "waiting": "Attendre", + "taskFailed": "La tâche a échoué", + "unknownSize": "Taille inconnue", + "secondsRemaining": "{count} secondes restantes", + "minutesRemaining": "{count} minutes restantes", + "hoursRemaining": "{count} heures restantes", + "secondsDuration": "{count} secondes", + "minutesDuration": "{count} minutes", + "hoursDuration": "{count} heures", + "followSystem": "Suivre le système", + "basicSettings": "Paramètres de base", + "downloadSettings": "Paramètres de téléchargement", + "advancedSettings": "Paramètres avancés", + "themeColor": "Couleur du thème", + "themeColorDescription": "Personnaliser la couleur de l'accent de l'application", + "browserExtensionDescription": "Envoyer les téléchargements de navigateur directement à Gopeed après l'installation", + "desktopNotificationDescription": "Afficher une notification système lorsqu'une tâche se termine ou échoue", + "backgroundLocationKeepAliveDescription": "Utiliser l'accès à l'emplacement pour maintenir les téléchargements actifs en arrière-plan", + "currentVersion": "Version actuelle {version}", + "contributors": "Cotisants", + "viewContributors": "Afficher les contributeurs", + "maxRunningDescription": "Nombre maximal de tâches pouvant être téléchargées en même temps", + "defaultUserAgentDescription": "Agent utilisateur par défaut pour les téléchargements HTTP et HTTPS", + "listenPort": "Écoute Port", + "onePerLine": "Une entrée par ligne", + "proxyProtocol": "Protocole de procuration", + "restartRequired": "Ce réglage prend effet après le redémarrage de l'application", + "socketPath": "Chemin de la prise", + "listenAddress": "Adresse de l'écoute", + "currentConnection": "Connexion actuelle", + "webhookPush": "Points d'arrivée du Webhook", + "webhookPushDescription": "Envoyer des notifications HTTP POST à ces paramètres lorsque les tâches sont terminées ou échouées", + "scriptExecution": "Scripts", + "scriptExecutionDescription": "Exécutez ces scripts après un téléchargement terminé avec succès", + "addWebhook": "Ajouter Webhook", + "editWebhook": "Modifier Webhook", + "addScript": "Ajouter un script", + "editScript": "Modifier le script", + "openDirectory": "Ouvrir le répertoire", + "browseFiles": "Parcourir les fichiers", + "updateEveryDay": "Mettre à jour quotidiennement", + "updateVersion": "Mise à jour", + "checkForUpdates": "Vérifier les mises à jour", + "updating": "Mise à jour...", + "colorGreen": "Vert", + "colorRed": "Rouge", + "colorRose": "Rose", + "colorOrange": "Orange", + "colorCyan": "Cyan", + "colorBlue": "Bleu", + "colorAmber": "Ambre", + "colorPurple": "Pourpre", + "noExtensions": "Pas de prolongation", + "searchExtensions": "Extensions de recherche...", + "extensionDeveloperGuide": "Extension guide développeur", + "extensionInstallFromUrl": "Installer l'extension depuis l'URL", + "unableOpenExtensionDocs": "Impossible d'ouvrir le guide du développeur d'extension", + "removeExtensionTitle": "Désinstaller l'extension ?", + "removeExtensionConfirm": "Etes-vous sûr de vouloir désinstaller « {name} » ?", + "uninstall": "Désinstaller", + "unableLoadExtensions": "Impossible de charger les extensions", + "filters": "Filtres", + "allTasks": "Toutes les tâches", + "searchDownloads": "Recherche de téléchargements...", + "searchHistory": "Rechercher dans l’historique…", + "addTask": "Ajouter une tâche", + "emptyTaskList": "Aucune tâche dans cette liste", + "noMatchingTasks": "Aucune tâche correspondante trouvée", + "taskList": "Liste des tâches", + "selectedCount": "{selected} / {total} sélectionné", + "unableOpenPath": "Impossible d'ouvrir {path}", + "unableLocatePath": "Impossible de localiser {path}", + "clearSelection": "Effacer la sélection", + "deselectTask": "Désélectionner", + "selectTask": "Sélectionner", + "updateManually": "Mettre à jour manuellement", + "stopListening": "Arrête d'écouter", + "listenForUrl": "Écouter l'URL", + "waitingForReplacementUrl": "En attente d'une URL de remplacement", + "taskNotFound": "Tâche non trouvée", + "storagePath": "Voie de stockage", + "directory": "Répertoire", + "mode": "Mode", + "keepOriginalName": "Conserver le nom original", + "enterCount": "Entrez le nombre", + "chooseDownloadDirectory": "Choisissez le répertoire de téléchargement", + "applicationStorage": "Stockage de l’application", + "applicationStorageDescription": "Stockage fiable de l’application ; les fichiers sont supprimés lors de sa désinstallation", + "publicDownloadsDescription": "Conserve les fichiers après la désinstallation ; la disponibilité dépend de l’appareil Android", + "downloadsDirectoryUnavailable": "Gopeed ne peut pas écrire dans Download/Gopeed sur cet appareil", + "enterValue": "Saisissez la valeur", + "trackers": "Traqueurs", + "oneTrackerPerLine": "Une URL de tracker par ligne", + "pasteDownloadLinks": "Coller des liens HTTP, HTTPS ou MAGNET, ou déposer un fichier torrent ici", + "unableReadTorrent": "Impossible de lire le fichier torrent : {error}", + "torrentOnly": "Seuls les fichiers .torrent sont pris en charge", + "torrentDataEmpty": "Les données du fichier Torrent sont vides", + "torrentPathUnavailable": "Impossible d'accéder au chemin de fichier torrent", + "selectAtLeastOneFile": "Sélectionnez au moins un fichier", + "resolvedFiles": "Fichiers résolus", + "resolvedFileCount": "{count} résolus", + "resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable", + "noFiles": "Aucun fichier", + "createHistory": "Créer l'historique", + "noHistory": "Pas d'histoire", + "directDownloadDescription": "Sautez la résolution et créez immédiatement des tâches", + "taskProgress": "Progrès accomplis dans la tâche", + "updateAndResume": "Mettre à jour et reprendre", + "httpHeaders": "En-têtes HTTP", + "piece": "Pièce {number}", + "pieceRange": "{start}–{end}", + "connectionStatus": "État de la connexion", + "connectionCount": "{count}", + "retrying": "Réessayer", + "connectionFailed": "La connexion a échoué", + "connectionFailedRetries": "après les nouvelles tentatives de {count}", + "retryingCount": "· Nouvelles tentatives {count}", + "pieceStatus": "État de la pièce", + "peers": "Pairs", + "seeders": "Sources", + "leechers": "Clients incomplets", + "downloadedAmount": "Téléchargé", + "uploadedAmount": "Téléchargé", + "shareRatio": "Ratio de partage", + "shareDuration": "Durée de partage", + "connectedPeers": "{count} connecté", + "active": "Activité", + "completedPieces": "Pièces complétées", + "activePeers": "{count} actif", + "incomplete": "Incomplète", + "address": "Adresse", + "client": "Client", + "pieces": "Pièces", + "source": "Source", + "fileRelevance": "Pertinence des fichiers", + "taskDetails": "Détails de la tâche", + "taskDetailsInfoTab": "Détails", + "taskDetailsFilesTab": "Fichiers", + "taskDetailsStatsTab": "Statistiques", + "noStatistics": "Pas de statistiques disponibles", + "noPeers": "Pas de connexions", + "deleteTasksTitle": "Supprimer les tâches {count} ?", + "keepFilesDescription": "Supprimez uniquement l'enregistrement de tâche de Gopeed", + "readingRuntimeStatus": "Lire le statut d'exécution...", + "apiConfigSaved": "Paramètres de l'API sauvegardés. Redémarrer l'application pour les appliquer", + "socketPathRequired": "Le chemin de la prise est nécessaire", + "serverRequired": "Serveur requis", + "portRangeError": "Entrez un port entre 0 et 65535", + "portInUseValue": "Le port {port} est déjà utilisé", + "backgroundLocationPermissionRequired": "Toujours permettre l'accès à l'emplacement est nécessaire pour les téléchargements de fond", + "scriptPath": "Chemin du script", + "logDirectoryUnavailable": "Le répertoire de log n'est pas initialisé", + "unableOpenExternalLink": "Impossible d'ouvrir le lien externe", + "neverUpdated": "Jamais mis à jour", + "lastUpdated": "Dernière mise à jour : {time}", + "unableLoadSettings": "Impossible de charger les paramètres", + "fieldRequired": "{field} est requis", + "addGithubMirror": "Ajouter un miroir GitHub", + "editGithubMirror": "Modifier le miroir GitHub", + "confirmDeleteTitle": "Confirmer la suppression ?", + "confirmDeleteNamed": "Etes-vous sûr de vouloir supprimer « {name} » ?", + "categoryFieldsRequired": "Le nom et le chemin de la catégorie sont requis", + "addCategory": "Ajouter une catégorie", + "editCategory": "Modifier la catégorie", + "deleteCategoryTitle": "Supprimer la catégorie?", + "updateFailed": "La mise à jour a échoué. Veuillez réessayer plus tard", + "newVersionAvailable": "Une nouvelle version est disponible", + "releaseNotes": "Quoi de neuf", + "noReleaseNotes": "Aucune note de sortie disponible", + "preparingDownload": "Préparation du téléchargement...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Service d’arrière-plan Gopeed", + "androidForegroundNotificationTitle": "Gopeed est en cours d’exécution", + "androidForegroundNotificationText": "Les téléchargements peuvent continuer en arrière-plan" +} diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb new file mode 100644 index 00000000..ca134dd6 --- /dev/null +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "hu", + "label": "Magyar", + "error": "Hiba", + "tip": "Tipp", + "confirm": "Megerősít", + "confirmDelete": "Biztosan törölni szeretné?", + "cancel": "Mégsem", + "on": "Bekapcsolva", + "off": "Kikapcsolva", + "selectAll": "Összes kiválasztása", + "select": "Kiválaszt", + "task": "Feladatok", + "downloading": "letöltés alatt", + "downloaded": "letöltve", + "setting": "Beállítások", + "donate": "Támogatás", + "exit": "Kilépés", + "pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez", + "create": "Feladat létrehozása", + "directDownload": "Közvetlen letöltés", + "advancedOptions": "Haladó beállítások", + "followSettings": "Beállítások követése", + "downloadLink": "Letöltési hivatkozás", + "downloadLinkValid": "Kérjük, adja meg a letöltési hivatkozást", + "downloadLinkHit": "Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append", + "downloadLinkHitDesktop": ", vagy húzza ide a torrent fájlt közvetlenül", + "download": "Letöltés", + "noFileSelected": "Kérjük, válasszon ki legalább egy fájlt a folytatáshoz", + "noStoragePermission": "Tárolási engedély szükséges", + "selectFile": "Fájl kiválasztása", + "rename": "Átnevezés", + "basic": "Alap", + "advanced": "Haladó", + "general": "Általános", + "downloadDir": "Letöltési könyvtár", + "rememberLastDownloadDir": "Legutóbbi feladatmappa automatikus mentése", + "downloadDirValid": "Kérjük, válassza ki a letöltési könyvtárat", + "connections": "Kapcsolatok", + "useServerCtime": "Használja a szerveridőt a fájl létrehozásához", + "maxRunning": "Maximális futó feladatok", + "defaultDirectDownload": "Alapértelmezettként ellenőrizze a közvetlen letöltést", + "autoStartTasks": "Befejezetlen feladatok automatikus indítása indításkor", + "autoTorrentEnable": "BT feladatok automatikus létrehozása .torrent fájlokból", + "autoTorrentDeleteAfterDownload": ".torrent fájl törlése a BT feladat létrehozása után", + "autoDeleteMissingFileTasks": "Hiányzó fájlokkal rendelkező feladatok automatikus törlése", + "items": "{count} elem", + "subscribeTracker": "Nyomkövető előfizetése", + "subscribeFail": "Az előfizetés nem sikerült, kérjük, ellenőrizze a hálózatot, vagy próbálja újra később", + "update": "Frissítés", + "updateDaily": "Napi frissítés", + "lastUpdate": "Utolsó frissítés: @time", + "addTracker": "Nyomkövető hozzáadása", + "addTrackerHit": "Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet", + "ui": "Felhasználói felület", + "theme": "Téma", + "themeSystem": "Rendszer", + "themeLight": "Világos", + "themeDark": "Sötét", + "locale": "Nyelv", + "notifyWhenNewVersion": "Értesítés a frissítésekről", + "analyticsEnabled": "Statisztikák feltöltése", + "analyticsEnabledDesc": "Oszd meg a névtelen használati adatokat, hogy segíts nekünk a fejlesztésben", + "about": "Névjegy", + "homepage": "Főoldal", + "version": "Verzió", + "protocol": "Protokoll", + "port": "port", + "apiToken": "API token", + "notSet": "NS", + "set": "BEÁLLÍT", + "portInUse": "A [@port] port használatban van, kérjük, változtassa meg a portot", + "effectAfterRestart": "Kihatás indítás után", + "developer": "Fejlesztő", + "logDirectory": "Napló könyvtár", + "webhook": "Webhook", + "webhookEnable": "Webhook engedélyezése", + "webhookDesc": "HTTP POST értesítések küldése, ha a feladatok befejeződnek vagy meghiúsulnak", + "webhookUrlHint": "Adja meg a webhook URL-címét", + "webhookTest": "teszt", + "webhookTestSuccess": "Webhook teszt sikeres", + "webhookTestFail": "Webhook teszt sikertelen", + "script": "Script", + "scriptEnable": "Szkript engedélyezése", + "scriptDesc": "Egyéni szkriptek végrehajtása a letöltés sikeres befejeződése után", + "scriptPathHint": "Enter script file path (e.g., /path/to/script.sh)", + "urlInvalid": "Kérjük, adjon meg egy érvényes HTTP vagy HTTPS URL-t", + "required": "This field is required", + "show": "Megjelenítés", + "continueAction": "Folyatás", + "pause": "Szünet", + "startAll": "Összes indítása", + "pauseAll": "Összes szüneteltetése", + "deleteTask": "@count feladat törlése", + "deleteTaskTip": "Letöltött fájlok megőrzése", + "delete": "Törlés", + "add": "Hozzáad", + "edit": "Szerk", + "newVersionTitle": "Új verzió felfedezése @version", + "newVersionUpdate": "Frissítés most", + "newVersionLater": "Később", + "extensions": "Bővítmények", + "extensionInstallUrl": "Telepítési URL", + "extensionInstallSuccess": "Sikeresen telepítve", + "extensionUpdateSuccess": "Sikeresen frissítve", + "extensionDelete": "Bővítmény törlése", + "extensionAlreadyLatest": "Ez már a legfrissebb verzió", + "extensionFind": "Bővítmények keresése", + "extensionDevelop": "Bővítmények fejlesztése", + "extensionStoreTitle": "Extension Store", + "extensionStoreSection": "Store Extensions", + "extensionInstalledSection": "Installed Extensions", + "extensionInstalledEmpty": "No extensions installed yet", + "extensionStoreEmpty": "No matching extensions found", + "extensionSearchHint": "Search by name, title, description or author", + "extensionSortStars": "A legtöbb csillag", + "extensionSortInstalls": "A legtöbb telepítés", + "extensionSortUpdated": "Nemrég frissítve", + "extensionSortToggle": "Toggle sort direction", + "extensionLoadMore": "Load More", + "extensionNoMore": "No more extensions", + "extensionInstalled": "Installed", + "extensionCanUpdate": "Update Available", + "extensionInstall": "Install", + "extensionLoadLocal": "Helyi mappa telepítése", + "extensionFilterAll": "All", + "extensionFilterMarket": "piac", + "extensionFilterInstalled": "telepítve", + "extensionManualInstall": "Manual Install", + "extensionInstallTools": "Install Options", + "history": "Előzmények", + "clearHistory": "Előzmények törlése", + "noHistoryFound": "Nincs előzménye", + "serviceTitle": "Letöltési szolgáltatás", + "serviceText": "Futtatás", + "network": "Hálózat", + "proxy": "proxy", + "noProxy": "Nincs proxy", + "systemProxy": "Rendszer proxy", + "customProxy": "Egyéni proxy", + "server": "Szerver", + "username": "Felhasználónév", + "password": "Jelszó", + "thanks": "Köszönet", + "thanksDesc": "Köszönet minden hozzájárulónak, aki segített a Gopeed közösség építésében és fejlesztésében!", + "browserExtension": "Böngésző bővítmény", + "launchAtStartup": "Indítás indításkor", + "runAsMenubarApp": "Futtatás menüsáv alkalmazásként", + "runAsMenubarAppDesc": "Dock ikon elrejtése és csak a menüsávban való futtatás", + "seedConfig": "Seed beállítás", + "seedKeep": "Tartsa a seedet amíg manuálisan le nem állítják", + "seedRatio": "Seed arány", + "seedTime": "Seed idő (perc)", + "setAsDefaultBtClient": "Alapértelmezett BT klienseként beállít", + "taskDetail": "Feladat részlete", + "taskName": "Feladat neve", + "taskUrl": "Feladat URL", + "downloadPath": "Letöltési útvonal", + "skipVerifyCert": "Tanúsítvány ellenőrzés átugrása", + "archives": "Archívumok", + "autoExtract": "Archívumok automatikus kicsomagolása", + "archivePassword": "Archívum jelszó", + "archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve", + "deleteAfterExtract": "Archívum törlése kicsomagolás után", + "extracting": "Kicsomagolás", + "extractDone": "Kicsomagolás befejezve", + "extractError": "Kicsomagolás sikertelen", + "waitingParts": "Várakozás a részekre", + "name": "Név", + "size": "Méret", + "unknown": "Ismeretlen", + "fileSelectedCount": "Fájlok: ", + "fileSelectedSize": "Méret: ", + "httpHeader": "Header", + "httpHeaderName": "Fejléc neve", + "httpHeaderValue": "Fejléc értéke", + "login": "Login", + "username_required": "Please enter your username", + "password_required": "Please enter your password", + "login_success": "Login successful", + "login_failed": "Login failed, please check your username and password", + "login_failed_network": "Login failed, please check your network connection", + "insertPlaceholder": "Helyőrző beszúrása", + "placeholderYear": "Aktuális év", + "placeholderMonth": "Aktuális hónap (01-12)", + "placeholderDay": "Aktuális nap (01-31)", + "placeholderDate": "Teljes dátum (ÉÉÉÉ-HH-NN)", + "example": "pl. @value", + "downloadCategories": "Letöltési kategóriák", + "categoryMusic": "Zene", + "categoryVideo": "Videó", + "categoryDocument": "Dokumentum", + "categoryProgram": "program", + "categoryName": "Kategória neve", + "categoryPath": "Kategória útvonala", + "builtInCategory": "A beépített kategória nem törölhető", + "selectCategory": "Kategória kiválasztása", + "quickDirectories": "Gyorsmappák", + "githubMirror": "GitHub tükör", + "githubMirrorEnable": "GitHub tükör engedélyezése", + "githubMirrorDesc": "Használjon tükröket a GitHub-tartalom letöltésének felgyorsításához", + "githubMirrorType": "tükör típus", + "githubMirrorUrl": "Tükör URL", + "githubMirrorUrlHint": "Enter mirror URL", + "updateUrl": "URL frissítése", + "updateUrlManual": "Manual Update", + "updateUrlListen": "Listen Update", + "updateUrlListeningTip": "Waiting for URL update", + "updateUrlCancelListen": "Cancel Listen", + "updateUrlDialogHint": "Enter new download URL", + "pendingUpdateFound": "Pending Update Task Found", + "pendingUpdateConfirm": "Task \"{name}\" is waiting for URL update. Do you want to update it with the new URL?", + "pendingUpdateYes": "Update Task", + "pendingUpdateNo": "Create New Task", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP hallgatási port", + "ed2kUdpPort": "UDP hallgatási port", + "ed2kServerList": "szervercímek", + "ed2kServerMet": "Server.met Források", + "ed2kNodesDat": "nodes.dat Források", + "ed2kAutoPort": "Auto assign", + "ed2kOnePerLine": "One entry per line", + "ed2kServersHint": "gazdagép:port, soronként egy", + "ed2kServerMetHint": "Server.met URL vagy ed2k szerverlista hivatkozás, soronként egy", + "ed2kNodesDatHint": "nodes.dat elérési út vagy URL, soronként egy", + "desktopNotification": "Asztali értesítések", + "backgroundLocationKeepAlive": "háttér letöltése Keep Alive", + "notificationTaskDone": "Feladat befejezve", + "notificationTaskError": "Feladat hiba", + "appTitle": "Gopeed letöltéskezelő", + "unsupportedWindowPayload": "Nem támogatott ablakkérelem", + "runtimeInitializationFailed": "A Gopeed futási ideje nem inicializálható. \n {error}", + "save": "Mentés", + "retry": "Próbáld újra", + "close": "Zárja be", + "back": "Vissza", + "open": "Nyitva", + "openFile": "Fájl megnyitása", + "share": "Megosztás", + "copy": "másolat", + "copied": "Másolva", + "refresh": "Frissítés", + "status": "állapot", + "createdAt": "Létrehozás ideje", + "speed": "sebesség", + "uploadSpeed": "Feltöltési sebesség", + "remaining": "Maradt", + "downloadDuration": "Letöltési idő", + "progress": "Haladás", + "failed": "sikertelen", + "completed": "Elkészült", + "waiting": "Waiting", + "taskFailed": "Task failed", + "unknownSize": "Ismeretlen méret", + "secondsRemaining": "{count, plural, =1{1 second remaining} other{{count} seconds remaining}}", + "minutesRemaining": "{count, plural, =1{1 minute remaining} other{{count} minutes remaining}}", + "hoursRemaining": "{count, plural, =1{1 hour remaining} other{{count} hours remaining}}", + "secondsDuration": "{count} másodperc", + "minutesDuration": "{count} perc", + "hoursDuration": "{count} óra", + "followSystem": "Kövesse a rendszert", + "basicSettings": "Alapbeállítások", + "downloadSettings": "Letöltési beállítások", + "advancedSettings": "Speciális beállítások", + "themeColor": "témaszín", + "themeColorDescription": "Az alkalmazás kiemelő színének testreszabása", + "browserExtensionDescription": "Küldje el a böngésző letöltéseit közvetlenül a Gopeednek a telepítés után", + "desktopNotificationDescription": "Rendszerértesítés megjelenítése, ha egy feladat befejeződik vagy meghiúsul", + "backgroundLocationKeepAliveDescription": "Használja a helyhozzáférést, hogy a letöltések aktívak maradjanak a háttérben", + "currentVersion": "Jelenlegi verzió {version}", + "contributors": "Közreműködők", + "viewContributors": "Közreműködők megtekintése", + "maxRunningDescription": "Az egyidejűleg letölthető feladatok maximális száma", + "defaultUserAgentDescription": "alapértelmezett felhasználói ügynök HTTP és HTTPS letöltésekhez", + "listenPort": "Hallgató port", + "onePerLine": "Soronként egy bejegyzés", + "proxyProtocol": "proxy protokoll", + "restartRequired": "Ez a beállítás az alkalmazás újraindítása után lép életbe", + "socketPath": "aljzat út", + "listenAddress": "Hallgatási cím", + "currentConnection": "Áramcsatlakozás", + "webhookPush": "Webhook végpontok", + "webhookPushDescription": "HTTP POST értesítések küldése ezekre a végpontokra, amikor a feladatok befejeződnek vagy meghiúsulnak", + "scriptExecution": "szkriptek", + "scriptExecutionDescription": "A letöltés sikeres befejezése után futtassa ezeket a szkripteket", + "addWebhook": "Webhook hozzáadása", + "editWebhook": "Webhook szerkesztése", + "addScript": "Szkript hozzáadása", + "editScript": "Szkript szerkesztése", + "openDirectory": "Open Directory", + "browseFiles": "Fájlok böngészése", + "updateEveryDay": "Naponta frissítés", + "updateVersion": "frissítés", + "checkForUpdates": "Frissítések keresése", + "updating": "Frissítés…", + "colorGreen": "Zöld", + "colorRed": "Piros", + "colorRose": "rózsa", + "colorOrange": "Narancs", + "colorCyan": "Cián", + "colorBlue": "Kék", + "colorAmber": "borostyán", + "colorPurple": "Lila", + "noExtensions": "Nincs bővítmény", + "searchExtensions": "Keresési bővítmények…", + "extensionDeveloperGuide": "bővítmény fejlesztői útmutató", + "extensionInstallFromUrl": "Bővítmény telepítése az URL-ről", + "unableOpenExtensionDocs": "Nem lehet megnyitni a bővítményfejlesztői útmutatót", + "removeExtensionTitle": "Kiterjesztés eltávolítása?", + "removeExtensionConfirm": "Biztos, hogy el kívánja távolítani a „{name}” programot?", + "uninstall": "Eltávolítás", + "unableLoadExtensions": "Nem lehet betölteni a bővítményeket", + "filters": "szűrők", + "allTasks": "Minden feladat", + "searchDownloads": "Letöltések keresése…", + "searchHistory": "Keresés az előzményekben…", + "addTask": "Feladat hozzáadása", + "emptyTaskList": "Nincsenek feladatok ebben a listában", + "noMatchingTasks": "Nem található a megfelelő feladat", + "taskList": "Feladatlista", + "selectedCount": "{selected} / {total} kiválasztva", + "unableOpenPath": "A {path} nem nyitható meg", + "unableLocatePath": "Nem található a {path}", + "clearSelection": "Tiszta kiválasztás", + "deselectTask": "Kijelölés megszüntetése", + "selectTask": "Kiválasztás", + "updateManually": "Manuális frissítés", + "stopListening": "Hagyd abba a hallgatást", + "listenForUrl": "Hallgassa meg az URL-t", + "waitingForReplacementUrl": "Csere URL-re vár", + "taskNotFound": "A feladat nem található", + "storagePath": "Tárolási útvonal", + "directory": "címtár", + "mode": "mód", + "keepOriginalName": "Az eredeti név megtartása", + "enterCount": "Adja meg a számot", + "chooseDownloadDirectory": "Válassza ki a letöltési könyvtárat", + "applicationStorage": "Alkalmazástárhely", + "applicationStorageDescription": "Megbízható alkalmazástárhely; az alkalmazás eltávolításakor a fájlok is törlődnek", + "publicDownloadsDescription": "Eltávolítás után is megőrzi a fájlokat; az elérhetőség az Android-eszköztől függ", + "downloadsDirectoryUnavailable": "A Gopeed nem tud írni a Download/Gopeed mappába ezen az eszközön", + "enterValue": "Adja meg az értéket", + "trackers": "nyomkövetők", + "oneTrackerPerLine": "Soronként egy nyomkövető URL", + "pasteDownloadLinks": "Illesszen be HTTP, HTTPS vagy MAGNET hivatkozásokat, vagy dobjon ide egy torrent fájlt", + "unableReadTorrent": "A torrent fájl nem olvasható: {error}", + "torrentOnly": "Csak a .torrent fájlok támogatottak", + "torrentDataEmpty": "A torrent fájl adatai üresek", + "torrentPathUnavailable": "Nem érhető el a torrent fájl elérési útja", + "selectAtLeastOneFile": "Válasszon ki legalább egy fájlt", + "resolvedFiles": "Feloldott fájlok", + "resolvedFileCount": "{count} fájlok megoldva", + "resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája", + "noFiles": "Nincsenek fájlok", + "createHistory": "Készítsen előzményeket", + "noHistory": "Nincs előzmény", + "directDownloadDescription": "Hagyja ki a megoldást, és azonnal hozzon létre feladatokat", + "taskProgress": "A feladat előrehaladása", + "updateAndResume": "Frissítés és folytatás", + "httpHeaders": "HTTP-fejlécek", + "piece": "darab {number}", + "pieceRange": "Darabok {start}–{end}", + "connectionStatus": "csatlakozási állapot", + "connectionCount": "{count} csatlakozások", + "retrying": "Újrapróbálkozás", + "connectionFailed": "A csatlakozás sikertelen", + "connectionFailedRetries": "A kapcsolat a {count} újrapróbálkozása után meghiúsult", + "retryingCount": "Újrapróbálkozás · {count} újrapróbálkozás", + "pieceStatus": "darab állapota", + "peers": "Partnerek", + "seeders": "Vetőgépek", + "leechers": "Letöltők", + "downloadedAmount": "Letöltve", + "uploadedAmount": "Feltöltve", + "shareRatio": "Megosztási arány", + "shareDuration": "Megosztási idő", + "connectedPeers": "{count} csatlakoztatva", + "active": "Aktív", + "completedPieces": "Kész darabok", + "activePeers": "{count} aktív", + "incomplete": "Hiányos", + "address": "Cím", + "client": "kliens", + "pieces": "darabok", + "source": "Forrás", + "fileRelevance": "Fájlrelevancia", + "taskDetails": "Feladat részletei", + "taskDetailsInfoTab": "Részletek", + "taskDetailsFilesTab": "fájlok", + "taskDetailsStatsTab": "Statisztika", + "noStatistics": "Nincs statisztika", + "noPeers": "Nincs csatlakozás", + "deleteTasksTitle": "Törli a {count} feladatokat?", + "keepFilesDescription": "Csak a feladatrekordot távolítsa el a Gopeed alkalmazásból", + "readingRuntimeStatus": "Futási állapot olvasása…", + "apiConfigSaved": "API beállítások mentve. Az alkalmazáshoz indítsa újra az alkalmazást", + "socketPathRequired": "Aljzatútvonal szükséges", + "serverRequired": "szerver szükséges", + "portRangeError": "Adjon meg egy portot 0 és 65535 között", + "portInUseValue": "A {port} port már használatban van", + "backgroundLocationPermissionRequired": "Mindig engedélyezze a helyhozzáférést a háttérben történő letöltésekhez", + "scriptPath": "szkript elérési útja", + "logDirectoryUnavailable": "A naplókönyvtár nincs inicializálva", + "unableOpenExternalLink": "Nem lehet megnyitni a külső hivatkozást", + "neverUpdated": "Soha nem frissítették", + "lastUpdated": "Utolsó frissítés: {time}", + "unableLoadSettings": "Nem lehet betölteni a beállításokat", + "fieldRequired": "{field} szükséges", + "addGithubMirror": "Adjon hozzá GitHub tükröt", + "editGithubMirror": "GitHub tükör szerkesztése", + "confirmDeleteTitle": "Megerősíti a törlést?", + "confirmDeleteNamed": "Biztosan törli a következőt: „{name}”?", + "categoryFieldsRequired": "A kategória neve és elérési útja kötelező", + "addCategory": "Kategória hozzáadása", + "editCategory": "Kategória szerkesztése", + "deleteCategoryTitle": "Kategória törlése?", + "updateFailed": "A frissítés nem sikerült. Kérjük, próbálja újra később", + "newVersionAvailable": "Új verzió érhető el", + "releaseNotes": "Újdonságok", + "noReleaseNotes": "Nem állnak rendelkezésre kiadási megjegyzések", + "preparingDownload": "Letöltés előkészítése…", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed háttérszolgáltatás", + "androidForegroundNotificationTitle": "A Gopeed fut", + "androidForegroundNotificationText": "A letöltések a háttérben is folytatódhatnak" +} diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb new file mode 100644 index 00000000..3e4f459c --- /dev/null +++ b/ui/flutter/lib/l10n/app_id.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "id", + "label": "Bahasa Indonesia", + "error": "Kesalahan", + "tip": "Tips", + "confirm": "Konfirmasi", + "confirmDelete": "Apakah Anda yakin ingin menghapus?", + "cancel": "Batal", + "on": "Aktif", + "off": "Nonaktif", + "selectAll": "Pilih Semua", + "select": "Pilih", + "task": "Tugas", + "downloading": "Mengunduh", + "downloaded": "Terunduh", + "setting": "Pengaturan", + "donate": "Donasi", + "exit": "Keluar", + "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", + "create": "Membuat Tugas", + "directDownload": "Unduhan Langsung", + "advancedOptions": "Pengaturan Lanjutan", + "followSettings": "Mengikuti Pengaturan", + "downloadLink": "Tautan Unduhan", + "downloadLinkValid": "Silakan masukkan tautan unduhan", + "downloadLinkHit": "Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append", + "downloadLinkHitDesktop": ", atau seret file torrent ke sini secara langsung", + "download": "Unduh", + "noFileSelected": "Silakan pilih setidaknya satu file untuk melanjutkan", + "noStoragePermission": "Izin penyimpanan diperlukan", + "selectFile": "Pilih File", + "rename": "Mengganti Nama", + "basic": "Dasar", + "advanced": "Lanjutan", + "general": "Umum", + "downloadDir": "Direktori Unduhan", + "rememberLastDownloadDir": "Simpan otomatis direktori tugas terakhir", + "downloadDirValid": "Silahkan pilih direktori unduhan", + "connections": "Koneksi", + "useServerCtime": "Gunakan waktu server untuk pembuatan file", + "maxRunning": "Tugas Maksimum yang Berjalan", + "defaultDirectDownload": "Pilih unduhan langsung sebagai opsi standar", + "autoStartTasks": "Otomatis-mulai tugas yang tidak lengkap pada startup", + "autoTorrentEnable": "Buat tugas BT secara otomatis dari file .torrent", + "autoTorrentDeleteAfterDownload": "Hapus file .torrent setelah membuat tugas BT", + "autoDeleteMissingFileTasks": "Otomatis hapus tugas dengan file yang hilang", + "items": "{count} item", + "subscribeTracker": "Berlangganan Tracker", + "subscribeFail": "Berlangganan gagal, periksa jaringan atau coba lagi nanti", + "update": "Perbarui", + "updateDaily": "Perbarui setiap hari", + "lastUpdate": "Pembaruan terakhir: @time", + "addTracker": "Tambah Tracker", + "addTrackerHit": "Silakan masukkan URL server tracker, satu per baris", + "ui": "Warna Tema", + "theme": "Tema", + "themeSystem": "Sistem", + "themeLight": "Terang", + "themeDark": "Gelap", + "locale": "Bahasa", + "notifyWhenNewVersion": "Beritahu pemutakhiran", + "analyticsEnabled": "Upload Statistik", + "analyticsEnabledDesc": "Berbagi data penggunaan anonim untuk membantu kita meningkatkan", + "about": "Tentang", + "homepage": "Beranda", + "version": "Versi", + "protocol": "Protokol", + "port": "Port", + "apiToken": "Token API", + "notSet": "Tidak Diatur", + "set": "ATUR", + "portInUse": "Port [@port] sedang digunakan, silakan ubah port", + "effectAfterRestart": "Berlaku setelah restart", + "developer": "Pengembang", + "logDirectory": "Direktori Log", + "webhook": "Webhook", + "webhookEnable": "Aktifkan Webhook", + "webhookDesc": "Kirim pemberitahuan POST HTTP ketika tugas selesai atau gagal", + "webhookUrlHint": "Masukkan URL webhook", + "webhookTest": "Uji", + "webhookTestSuccess": "Tes webhook berhasil", + "webhookTestFail": "gagal", + "script": "Skrip", + "scriptEnable": "Aktifkan Skrip", + "scriptDesc": "Jalankan skrip gubahan ketika pengunduhan sukses selesai", + "scriptPathHint": "Masukkan alamat berkas skrip (mis., / path / to / script.sh)", + "urlInvalid": "Silakan masukkan URL HTTP atau HTTPS yang valid", + "required": "Bidang ini diperlukan", + "show": "Tampilkan", + "continueAction": "Lanjutkan", + "pause": "Jeda", + "startAll": "Mulai Semua", + "pauseAll": "Jeda Semua", + "deleteTask": "Hapus @count Tugas", + "deleteTaskTip": "Simpan file yang terunduh", + "delete": "Hapus", + "add": "Tambah", + "edit": "Sunting", + "newVersionTitle": "Temukan versi batu @version", + "newVersionUpdate": "Perbarui Sekarang", + "newVersionLater": "Nanti", + "extensions": "Ekstensi", + "extensionInstallUrl": "URL Instal", + "extensionInstallSuccess": "Diinstal dengan sukses", + "extensionUpdateSuccess": "Diperbaruhi dengan sukses", + "extensionDelete": "Hapus Ekstensi", + "extensionAlreadyLatest": "Ini sudah versi terbaru", + "extensionFind": "Temukan Ekstensi", + "extensionDevelop": "Kembangkan Ekstensi", + "extensionStoreTitle": "Toko Ekstensi", + "extensionStoreSection": "Ekstensi Toko", + "extensionInstalledSection": "Ekstensi Terpasang", + "extensionInstalledEmpty": "Belum ada ekstensi yang terpasang", + "extensionStoreEmpty": "Tidak ditemukan ekstensi yang cocok", + "extensionSearchHint": "Cari dengan nama, judul, deskripsi, atau penulis", + "extensionSortStars": "Kebanyakan Bintang", + "extensionSortInstalls": "Sebagian besar Install", + "extensionSortUpdated": "Baru-baru ini Diperbarui", + "extensionSortToggle": "Ubah arah pengurutan", + "extensionLoadMore": "Muat Lebih Banyak", + "extensionNoMore": "Tidak ada lagi ekstensi", + "extensionInstalled": "Terpasang", + "extensionCanUpdate": "Mutakhirkan Tersedia", + "extensionInstall": "Pasang", + "extensionLoadLocal": "Pasang Folder Lokal", + "extensionFilterAll": "Semua", + "extensionFilterMarket": "Pasar", + "extensionFilterInstalled": "Terpasang", + "extensionManualInstall": "Pasang Manual", + "extensionInstallTools": "Pasang Opsi", + "history": "Riwayat", + "clearHistory": "Hapus Riwayat", + "noHistoryFound": "Riwayat Tidak DItemukan", + "serviceTitle": "Layanan Unduhan", + "serviceText": "Sedang Berjalan", + "network": "Jaringan", + "proxy": "Protokol Proksi", + "noProxy": "Tanpa Proxy", + "systemProxy": "Proxy Sistem", + "customProxy": "Proxy Kustom", + "server": "Server", + "username": "Nama Pengguna", + "password": "Kata Sandi", + "thanks": "Terima Kasih", + "thanksDesc": "Terima kasih kepada semua kontributor yang telah membantu membangun dan mengembangkan komunitas Gopeed!", + "browserExtension": "Ekstensi Perambanan", + "launchAtStartup": "Luncurkan saat Startup", + "runAsMenubarApp": "Jalankan sebagai aplikasi bilah menu", + "runAsMenubarAppDesc": "Sembunyikan ikon Dock dan jalankan hanya di bilah menu", + "seedConfig": "Konfigurasi Seed", + "seedKeep": "Terus seeding hingga dihentikan secara manual", + "seedRatio": "Rasio Seed", + "seedTime": "Waktu Seed (menit)", + "setAsDefaultBtClient": "Atur sebagai klien baku BT", + "taskDetail": "Detail Tugas", + "taskName": "Nama Tugas", + "taskUrl": "URL Tugas", + "downloadPath": "Direktori Unduhan", + "skipVerifyCert": "Lupakan verifikasi sertifikat", + "archives": "Arsip", + "autoExtract": "Ekstrak Arsip Otomatis", + "archivePassword": "Kata Sandi Arsip", + "archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi", + "deleteAfterExtract": "Hapus arsip setelah ekstraksi", + "extracting": "Mengekstrak", + "extractDone": "Ekstraksi selesai", + "extractError": "Ekstraksi gagal", + "waitingParts": "Menunggu bagian", + "name": "Nama", + "size": "Ukuran", + "unknown": "Tak dikenal", + "fileSelectedCount": "Berkas:", + "fileSelectedSize": "Ukuran:", + "httpHeader": "Tajuk", + "httpHeaderName": "Nama Header", + "httpHeaderValue": "Nilai Tajuk", + "login": "Log masuk", + "username_required": "Silakan masukkan nama pengguna Anda", + "password_required": "Masukkan sandi Anda", + "login_success": "Login sukses", + "login_failed": "Log masuk gagal, silakan periksa nama pengguna dan sandi Anda", + "login_failed_network": "Log masuk gagal, silakan periksa koneksi jaringan Anda", + "insertPlaceholder": "Sisipkan placeholder", + "placeholderYear": "Tahun saat ini", + "placeholderMonth": "Bulan saat ini (01-12)", + "placeholderDay": "Hari saat ini (01-31)", + "placeholderDate": "Tanggal lengkap (YYYY-MM-DD)", + "example": "cth. @value", + "downloadCategories": "Kategori Unduhan", + "categoryMusic": "Musik", + "categoryVideo": "Program", + "categoryDocument": "Dokumen", + "categoryProgram": "Program", + "categoryName": "Nama Kategori", + "categoryPath": "Path Kategori", + "builtInCategory": "Kategori bawaan tidak dapat dihapus", + "selectCategory": "Pilih Kategori", + "quickDirectories": "Folder cepat", + "githubMirror": "Cermin GitHub", + "githubMirrorEnable": "Aktifkan Cermin GitHub", + "githubMirrorDesc": "Gunakan cermin untuk mempercepat pengunduhan isi GitHub", + "githubMirrorType": "Tipe Cermin", + "githubMirrorUrl": "Refleks URL", + "githubMirrorUrlHint": "Masukkan URL cermin", + "updateUrl": "Mutakhirkan URL", + "updateUrlManual": "Pemutakhiran Manual", + "updateUrlListen": "Dengarkan Perbarui", + "updateUrlListeningTip": "Menunggu pemutakhiran URL", + "updateUrlCancelListen": "Batalkan Dengarkan", + "updateUrlDialogHint": "Masukkan URL unduh baru", + "pendingUpdateFound": "Pencarian Tugas Yang Ditunda", + "pendingUpdateConfirm": "Tugas \"{name}\" sedang menunggu pemutakhiran URL. Anda ingin memutakhirkan dengan URL baru?", + "pendingUpdateYes": "Mutakhirkan Tugas", + "pendingUpdateNo": "Buat Tugas Baru", + "ed2k": "ED2K", + "ed2kTcpPort": "Port Dengarkan TCP", + "ed2kUdpPort": "Alamat Server", + "ed2kServerList": "Alamat Server", + "ed2kServerMet": "Server.met Sumber", + "ed2kNodesDat": "node.dat Sumber", + "ed2kAutoPort": "Otomatis diberikan", + "ed2kOnePerLine": "Satu entri per baris", + "ed2kServersHint": "host: port, satu per baris", + "ed2kServerMetHint": "Server.met URL atau tautan daftar server ed2k, satu per baris", + "ed2kNodesDatHint": ", satu per baris", + "desktopNotification": "Notifikasi Desktop", + "backgroundLocationKeepAlive": "Unduh Latar Belakang Tetap Hidup", + "notificationTaskDone": "Tugas selesai", + "notificationTaskError": "Tugas error", + "appTitle": "Pengelola Unduhan Gopeed", + "unsupportedWindowPayload": "Permintaan jendela tak didukung", + "runtimeInitializationFailed": "Tidak dapat menginisialisasi runtime Gopeed. \n {error}", + "save": "Simpan", + "retry": "Coba lagi", + "close": "Tutup", + "back": "Kembali", + "open": "Buka", + "openFile": "Buka Berkas", + "share": "Bagikan", + "copy": "Salin", + "copied": "Disalin", + "refresh": "Segarkan", + "status": "Status", + "createdAt": "Waktu dibuat", + "speed": "Kecepatan", + "uploadSpeed": "Kecepatan pemutakhiran", + "remaining": "Sisa", + "downloadDuration": "Durasi unduhan", + "progress": "Kemajuan", + "failed": "Gagal", + "completed": "Selesai", + "waiting": "Menunggu", + "taskFailed": "Tugas gagal", + "unknownSize": "Ukuran tidak diketahui", + "secondsRemaining": "{count} detik tersisa", + "minutesRemaining": "{count} menit tersisa", + "hoursRemaining": "{count} jam tersisa", + "secondsDuration": "{count} detik", + "minutesDuration": "{count} menit", + "hoursDuration": "{count} jam", + "followSystem": "Ikuti sistem", + "basicSettings": "Pengaturan Dasar", + "downloadSettings": "Pengaturan Unduh", + "advancedSettings": "Pengaturan Lanjutan", + "themeColor": "Warna Tema", + "themeColorDescription": "Menggubah warna aksen aplikasi", + "browserExtensionDescription": "Kirim peramban diunduh langsung ke Gopeed setelah instalasi", + "desktopNotificationDescription": "Tampilkan pemberitahuan sistem ketika suatu tugas komplit atau gagal", + "backgroundLocationKeepAliveDescription": "Gunakan akses lokasi untuk menjaga pengunduhan aktif di latar belakang", + "currentVersion": "Versi saat ini {version}", + "contributors": "Kontributor", + "viewContributors": "Tilik kontributor", + "maxRunningDescription": "Jumlah tugas maksimum yang dapat diunduh pada saat bersamaan", + "defaultUserAgentDescription": "Agen Pengguna Default untuk unduhan HTTP dan HTTPS", + "listenPort": "Dengarkan Port", + "onePerLine": "Satu entri per baris", + "proxyProtocol": "Protokol Proxy", + "restartRequired": "Pengaturan ini berlaku setelah memulai ulang aplikasi", + "socketPath": "Path Socket", + "listenAddress": "Dengar Alamat", + "currentConnection": "Koneksi Saat Ini", + "webhookPush": "Endpoints Webhook", + "webhookPushDescription": "Kirim pemberitahuan POST HTTP ke titik akhir ketika tugas selesai atau gagal", + "scriptExecution": "Skrip", + "scriptExecutionDescription": "Jalankan skrip ini setelah suatu unduh sukses menyelesaikan", + "addWebhook": "Tambah Bowhook", + "editWebhook": "Sunting Webhook", + "addScript": "Tambah Skrip", + "editScript": "Sunting Skrip", + "openDirectory": "Buka Direktori", + "browseFiles": "Jelajahi file", + "updateEveryDay": "Perbarui setiap hari", + "updateVersion": "Perbarui", + "checkForUpdates": "Periksa Pemutakhiran", + "updating": "Memperbarui...", + "colorGreen": "Hijau", + "colorRed": "Merah", + "colorRose": "Rose", + "colorOrange": "Orange", + "colorCyan": "Cyan", + "colorBlue": "Biru", + "colorAmber": "Amber", + "colorPurple": "Ungu", + "noExtensions": "Tidak ada ekstensi", + "searchExtensions": "Cari ekstensi...", + "extensionDeveloperGuide": "Panduan pengembang ekstensi", + "extensionInstallFromUrl": "Pasang ekstensi dari URL", + "unableOpenExtensionDocs": "Tidak dapat membuka panduan pengembang ekstensi", + "removeExtensionTitle": "Melepas ekstensi?", + "removeExtensionConfirm": "Apakah Anda yakin ingin menghapus instalasi “{name}”?", + "uninstall": "Batalkan", + "unableLoadExtensions": "Tidak dapat memuat ekstensi", + "filters": "Filter", + "allTasks": "Semua Tugas", + "searchDownloads": "Cari pengunduhan...", + "searchHistory": "Cari riwayat…", + "addTask": "Tambah Tugas", + "emptyTaskList": "Tidak ada tugas dalam daftar ini", + "noMatchingTasks": "Tugas yang cocok tak ditemukan", + "taskList": "Daftar Tugas", + "selectedCount": "{selected} / {total} dipilih", + "unableOpenPath": "Tidak dapat membuka {path}", + "unableLocatePath": "Tidak dapat menemukan {path}", + "clearSelection": "Bersihkan bagian yang dipilih", + "deselectTask": "Batal pilih", + "selectTask": "Pilih", + "updateManually": "Perbarui secara manual", + "stopListening": "Berhenti mendengarkan", + "listenForUrl": "Dengarkan URL", + "waitingForReplacementUrl": "Menunggu URL pengganti", + "taskNotFound": "Tugas tak ditemukan", + "storagePath": "Path Penyimpanan", + "directory": "Direktori", + "mode": "Mode", + "keepOriginalName": "Pertahankan nama asli", + "enterCount": "Masukkan hitungan", + "chooseDownloadDirectory": "Pilih direktori unduh", + "applicationStorage": "Penyimpanan aplikasi", + "applicationStorageDescription": "Penyimpanan aplikasi yang andal; file dihapus saat aplikasi dihapus instalasinya", + "publicDownloadsDescription": "Menyimpan file setelah aplikasi dihapus; ketersediaan bergantung pada perangkat Android", + "downloadsDirectoryUnavailable": "Gopeed tidak dapat menulis ke Download/Gopeed di perangkat ini", + "enterValue": "Masukkan nilai", + "trackers": "Pelacak", + "oneTrackerPerLine": "Satu URL pelacak per baris", + "pasteDownloadLinks": "Tempel HTTP, HTTPS, atau taut MAGNET, atau taruh berkas torrent di sini", + "unableReadTorrent": "Tidak dapat membaca file torrent: {error}", + "torrentOnly": "Hanya berkas .torrent yang didukung", + "torrentDataEmpty": "Data berkas Torrent kosong", + "torrentPathUnavailable": "Tak bisa mengakses path berkas torrent", + "selectAtLeastOneFile": "Pilih paling tidak satu berkas", + "resolvedFiles": "Berkas Terpecahkan", + "resolvedFileCount": "{count} terselesaikan", + "resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih", + "noFiles": "Tidak ada berkas", + "createHistory": "Buat Riwayat", + "noHistory": "Tidak ada sejarah", + "directDownloadDescription": "Lewatkan pemecahan dan buat tugas segera", + "taskProgress": "Kemajuan tugas", + "updateAndResume": "Perbarui dan lanjutkan", + "httpHeaders": "Header HTTP", + "piece": "Potongan {number}", + "pieceRange": "Potongan {start}–{end}", + "connectionStatus": "Status Koneksi", + "connectionCount": "{count}", + "retrying": "Mencoba", + "connectionFailed": "Koneksi gagal", + "connectionFailedRetries": "gagal setelah {count} mencoba lagi", + "retryingCount": "Mencoba Ulang · {count} mencoba lagi", + "pieceStatus": "Status Potongan", + "peers": "Rekan", + "seeders": "Seeder", + "leechers": "Leecher", + "downloadedAmount": "Terunduh", + "uploadedAmount": "Terunggah", + "shareRatio": "Rasio berbagi", + "shareDuration": "Durasi berbagi", + "connectedPeers": "{count} terhubung", + "active": "Aktif", + "completedPieces": "Potongan-potongan yang telah selesai", + "activePeers": "{count} aktif", + "incomplete": "Tidak lengkap", + "address": "Alamat", + "client": "Klien", + "pieces": "Potongan", + "source": "Sumber", + "fileRelevance": "Relevansi berkas", + "taskDetails": "Rincian Tugas", + "taskDetailsInfoTab": "Rincian", + "taskDetailsFilesTab": "Berkas", + "taskDetailsStatsTab": "Statistik", + "noStatistics": "Tak ada statistik yang tersedia", + "noPeers": "Tidak ada koneksi", + "deleteTasksTitle": "Hapus tugas {count}?", + "keepFilesDescription": "Hanya menghapus catatan tugas dari Gopeed", + "readingRuntimeStatus": "Membaca status waktu-jalan...", + "apiConfigSaved": "Pengaturan API disimpan. Jalankan ulang aplikasi untuk menerapkannya", + "socketPathRequired": "Path socket dibutuhkan", + "serverRequired": "Server diperlukan", + "portRangeError": "Masukkan sebuah port antara 0 dan 65535", + "portInUseValue": "Port {port} sudah digunakan", + "backgroundLocationPermissionRequired": "Selalu ijinkan akses lokasi diperlukan untuk unduhan latar belakang", + "scriptPath": "Path Skrip", + "logDirectoryUnavailable": "Direktori log tidak diinisialisasi", + "unableOpenExternalLink": "Tidak dapat membuka taut eksternal", + "neverUpdated": "Tak pernah diperbarui", + "lastUpdated": "Terakhir diperbarui: {time}", + "unableLoadSettings": "Tak bisa memuat pengaturan", + "fieldRequired": "{field} diperlukan", + "addGithubMirror": "Tambah Cermin GitHub", + "editGithubMirror": "Sunting Cermin GitHub", + "confirmDeleteTitle": "Konfirmasi penghapusan?", + "confirmDeleteNamed": "Apakah Anda yakin ingin menghapus “{name}”?", + "categoryFieldsRequired": "Nama dan jalur katagori diperlukan", + "addCategory": "Tambah Kategori", + "editCategory": "Sunting Kategori", + "deleteCategoryTitle": "Hapus kategori?", + "updateFailed": "Pemutakhiran gagal. Silakan coba lagi nanti", + "newVersionAvailable": "Versi baru tersedia", + "releaseNotes": "Apa yang baru", + "noReleaseNotes": "Tidak ada catatan rilis yang tersedia", + "preparingDownload": "Bersiap mengunduh...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Layanan latar belakang Gopeed", + "androidForegroundNotificationTitle": "Gopeed sedang berjalan", + "androidForegroundNotificationText": "Unduhan dapat dilanjutkan di latar belakang" +} diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb new file mode 100644 index 00000000..a49aa95f --- /dev/null +++ b/ui/flutter/lib/l10n/app_it.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "it", + "label": "Italiano", + "error": "Errore", + "tip": "Suggerimento", + "confirm": "Conferma", + "confirmDelete": "Sei sicuro di voler eliminare?", + "cancel": "Annullla", + "on": "Acceso", + "off": "Spento", + "selectAll": "Seleziona tutto", + "select": "Seleziona", + "task": "Attività", + "downloading": "scaricamento in corso", + "downloaded": "scaricato", + "setting": "Impostazioni", + "donate": "Donazione", + "exit": "Uscita", + "pressBackAgainToExit": "Premi di nuovo Indietro per uscire", + "create": "Crea attività", + "directDownload": "Scaricamento diretto", + "advancedOptions": "Opzioni avanzate", + "followSettings": "Seguire le impostazioni", + "downloadLink": "Link di scaricamento", + "downloadLinkValid": "Inserisci il collegamento per lo scaricamento", + "downloadLinkHit": "Inserisci il collegamento per il download, HTTP/HTTPS/MAGNET supported@append", + "downloadLinkHitDesktop": ", oppure trascina qui direttamente il file torrent", + "download": "Scarica", + "noFileSelected": "Seleziona almeno un file per continuare", + "noStoragePermission": "È richiesta l'autorizzazione di archiviazione", + "selectFile": "Selezione file", + "rename": "Rinomina", + "basic": "Basico", + "advanced": "Avanzato", + "general": "Generale", + "downloadDir": "Directory di scaricamento", + "rememberLastDownloadDir": "Salva automaticamente l'ultima cartella dell'attività", + "downloadDirValid": "Seleziona la directory di scaricamento", + "connections": "Connessioni", + "useServerCtime": "Utilizza l'ora del server per la creazione dei file", + "maxRunning": "Numero massimo di attività in esecuzione", + "defaultDirectDownload": "Controlla il download diretto per impostazione predefinita", + "autoStartTasks": "Avviare attività incomplete all'avvio", + "autoTorrentEnable": "Crea automaticamente attività BT da file .torrent", + "autoTorrentDeleteAfterDownload": "Elimina file .torrent dopo creazione attività BT", + "autoDeleteMissingFileTasks": "Elimina automaticamente le attività con file mancanti", + "items": "{count} elementi", + "subscribeTracker": "Iscriviti al tracker", + "subscribeFail": "Iscrizione non riuscita, controlla la rete o riprova più tardir", + "update": "Aggiornaento", + "updateDaily": "Aggiornamento quotidiano", + "lastUpdate": "Ultimo aggiornamento: @time", + "addTracker": "Aggiungi Tracker", + "addTrackerHit": "Inserisci l'URL del server del tracker, uno per riga", + "ui": "Colore tema", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Chiaro", + "themeDark": "Scuro", + "locale": "Lingua", + "notifyWhenNewVersion": "Inviare aggiornamenti", + "analyticsEnabled": "Statistiche di carico", + "analyticsEnabledDesc": "Condividere dati di utilizzo anonimi per aiutarci a migliorare", + "about": "Informazioni", + "homepage": "Pagina iniziale", + "version": "Versione", + "protocol": "Protocollo", + "port": "Porta", + "apiToken": "Token API", + "notSet": "NS", + "set": "SET", + "portInUse": "La porta [@port] è in uso, per favore cambia la porta", + "effectAfterRestart": "Effetto dopo il riavvio", + "developer": "Sviluppatore", + "logDirectory": "Cartella dei log", + "webhook": "Webhook", + "webhookEnable": "Attiva Webhook", + "webhookDesc": "Invia notifiche HTTP POST quando le attività sono complete o non vengono eseguite", + "webhookUrlHint": "Inserisci l'URL di webhook", + "webhookTest": "Test di prova", + "webhookTestSuccess": "Test del webhook riuscito", + "webhookTestFail": "Test del webhook fallito", + "script": "Script", + "scriptEnable": "Abilitare lo script", + "scriptDesc": "Eseguire script personalizzati quando i download completano con successo", + "scriptPathHint": "Inserisci il percorso del file script (ad esempio, /path/to/script.sh)", + "urlInvalid": "Inserisci un URL HTTP o HTTPS valido", + "required": "Questo campo è richiesto", + "show": "Mostra", + "continueAction": "Continua", + "pause": "Pausa", + "startAll": "Avvia tutti", + "pauseAll": "Mtti in pausa tutti", + "deleteTask": "Elimina @count attività", + "deleteTaskTip": "Conserva i file scaricati", + "delete": "Elimina", + "add": "Aggiungi", + "edit": "Modifica", + "newVersionTitle": "Scopri la nuova versione @version", + "newVersionUpdate": "Aggiorna ora", + "newVersionLater": "Dopo", + "extensions": "Estensioni", + "extensionInstallUrl": "Installa URL", + "extensionInstallSuccess": "Installato con successo", + "extensionUpdateSuccess": "Aggiornato con successo", + "extensionDelete": "Elimina estensione", + "extensionAlreadyLatest": "È già l'ultima versione", + "extensionFind": "Trova estensioni", + "extensionDevelop": "Sviluppare estensioni", + "extensionStoreTitle": "Negozio di estensione", + "extensionStoreSection": "Estensioni del negozio", + "extensionInstalledSection": "Estensioni installate", + "extensionInstalledEmpty": "Nessuna estensione installata ancora", + "extensionStoreEmpty": "Nessuna estensione di corrispondenza trovata", + "extensionSearchHint": "Ricerca per nome, titolo, descrizione o autore", + "extensionSortStars": "La maggior parte delle stelle", + "extensionSortInstalls": "La maggior parte delle installazioni", + "extensionSortUpdated": "Recentemente aggiornato", + "extensionSortToggle": "Toggle direzione di selezione", + "extensionLoadMore": "Caricamento più", + "extensionNoMore": "Non più estensioni", + "extensionInstalled": "Installato", + "extensionCanUpdate": "Aggiornamento disponibile", + "extensionInstall": "Installazione", + "extensionLoadLocal": "Installare cartella locale", + "extensionFilterAll": "Tutti", + "extensionFilterMarket": "Mercato", + "extensionFilterInstalled": "Installato", + "extensionManualInstall": "Installazione manuale", + "extensionInstallTools": "Opzioni di installazione", + "history": "Cronologia", + "clearHistory": "Pulisci cronologia", + "noHistoryFound": "Nessuna cronologia trovata", + "serviceTitle": "Scarica il servizio", + "serviceText": "In esecuzione", + "network": "Rete", + "proxy": "Protocollo proxy", + "noProxy": "Nessun Proxy", + "systemProxy": "Sistema Proxy", + "customProxy": "Proxy personalizzato", + "server": "Server", + "username": "Nome utente", + "password": "Password", + "thanks": "Ringraziamenti", + "thanksDesc": "Grazie a tutti i contributori che hanno contribuito a costruire e sviluppare la comunità Gopeed!", + "browserExtension": "Estensione del browser", + "launchAtStartup": "Lancia all'avvio", + "runAsMenubarApp": "Esegui come app della barra dei menu", + "runAsMenubarAppDesc": "Nascondi icona Dock ed esegui solo nella barra dei menu", + "seedConfig": "Configurazione dei semi", + "seedKeep": "Continuare a vedere fino a quando non è stato interrotto manualmente", + "seedRatio": "Rapporto di taglio", + "seedTime": "Tempo di seme (minuti)", + "setAsDefaultBtClient": "Impostare come client BT predefinito", + "taskDetail": "Dettaglio delle attività", + "taskName": "Nome della missione", + "taskUrl": "URL del task", + "downloadPath": "Scarica il percorso", + "skipVerifyCert": "Salta la verifica del certificato", + "archives": "Archivi", + "autoExtract": "Estrai automaticamente gli archivi", + "archivePassword": "Password archivio", + "archivePasswordHint": "Lasciare vuoto se non protetto da password", + "deleteAfterExtract": "Elimina l'archivio dopo l'estrazione", + "extracting": "Estrazione in corso", + "extractDone": "Estrazione completata", + "extractError": "Estrazione fallita", + "waitingParts": "In attesa delle parti", + "name": "Nome", + "size": "Dimensione", + "unknown": "Sconosciuto", + "fileSelectedCount": "File:", + "fileSelectedSize": "Dimensioni:", + "httpHeader": "Intestazione", + "httpHeaderName": "Nome dell'intestazione", + "httpHeaderValue": "Valore di intestazione", + "login": "Accedi", + "username_required": "Inserisci il tuo nome utente", + "password_required": "Inserisci la password", + "login_success": "Accedi a successo", + "login_failed": "Login non riuscito, si prega di controllare il nome utente e la password", + "login_failed_network": "Login non riuscito, controllare la connessione di rete", + "insertPlaceholder": "Inserisci segnaposto", + "placeholderYear": "Anno corrente", + "placeholderMonth": "Mese corrente (01-12)", + "placeholderDay": "Giorno corrente (01-31)", + "placeholderDate": "Data completa (AAAA-MM-GG)", + "example": "es. @value", + "downloadCategories": "Categorie di download", + "categoryMusic": "Musica", + "categoryVideo": "Video", + "categoryDocument": "Documento", + "categoryProgram": "Programma", + "categoryName": "Nome categoria", + "categoryPath": "Percorso categoria", + "builtInCategory": "La categoria predefinita non può essere eliminata", + "selectCategory": "Seleziona categoria", + "quickDirectories": "Cartelle rapide", + "githubMirror": "Specchio GitHub", + "githubMirrorEnable": "Attivare lo specchio GitHub", + "githubMirrorDesc": "Utilizzare specchi per accelerare i download di contenuti GitHub", + "githubMirrorType": "Tipo di specchio", + "githubMirrorUrl": "URL del mirror", + "githubMirrorUrlHint": "Inserisci l'URL dello specchio", + "updateUrl": "URL dell'aggiornamento", + "updateUrlManual": "Aggiornamento manuale", + "updateUrlListen": "Ascoltare Aggiornamento", + "updateUrlListeningTip": "In attesa di aggiornamento URL", + "updateUrlCancelListen": "Annullare la", + "updateUrlDialogHint": "Inserisci nuovo URL di download", + "pendingUpdateFound": "Attività di aggiornamento in attesa Trovato", + "pendingUpdateConfirm": "Task \"{name}\" è in attesa di aggiornamento URL. Vuoi aggiornarlo con il nuovo URL?", + "pendingUpdateYes": "Attività di aggiornamento", + "pendingUpdateNo": "Creare un nuovo task", + "ed2k": "ED2K", + "ed2kTcpPort": "Porta di ascolto TCP", + "ed2kUdpPort": "Porta di ascolto UDP", + "ed2kServerList": "Host", + "ed2kServerMet": "Sorgenti Server.met", + "ed2kNodesDat": "nodes.dat", + "ed2kAutoPort": "Assegnazione automatica", + "ed2kOnePerLine": "Una voce per linea", + "ed2kServersHint": ": porta, una per linea", + "ed2kServerMetHint": "URL Server.met o collegamento serverlist ed2k, uno per riga", + "ed2kNodesDatHint": "percorso o URL del file nodes.dat, uno per riga", + "desktopNotification": "Notifiche Desktop", + "backgroundLocationKeepAlive": "Scarica il sito", + "notificationTaskDone": "Attività completata", + "notificationTaskError": "Errore attività", + "appTitle": "Gestore download Gopeed", + "unsupportedWindowPayload": "Richiesta finestra non supportata", + "runtimeInitializationFailed": "Impossibile inizializzare il runtime Gopeed. \n {error}", + "save": "Salva", + "retry": "Retribuzioni", + "close": "Chiudi", + "back": "Indietro", + "open": "Apri", + "openFile": "Apri file", + "share": "Condividi", + "copy": "Copia", + "copied": "- Si'", + "refresh": "Aggiorna", + "status": "Stato", + "createdAt": "Data di creazione", + "speed": "Velocità", + "uploadSpeed": "Velocità di carico", + "remaining": "Rimanere", + "downloadDuration": "Durata del download", + "progress": "Progressi", + "failed": "Non è successo", + "completed": "Completato", + "waiting": "Aspettare", + "taskFailed": "Compito fallito", + "unknownSize": "Dimensione sconosciuta", + "secondsRemaining": "{count} secondi rimanenti", + "minutesRemaining": "{count} minuti rimanenti", + "hoursRemaining": "{count} ore rimanenti", + "secondsDuration": "{count} secondi", + "minutesDuration": "{count} minuti", + "hoursDuration": "{count} ore", + "followSystem": "Seguire il sistema", + "basicSettings": "Impostazioni di base", + "downloadSettings": "Impostazioni di download", + "advancedSettings": "Impostazioni avanzate", + "themeColor": "Colore tema", + "themeColorDescription": "Personalizza il colore dell'accento app", + "browserExtensionDescription": "Invia download del browser direttamente a Gopeed dopo l'installazione", + "desktopNotificationDescription": "Mostra una notifica di sistema quando un'attività completa o non riesce", + "backgroundLocationKeepAliveDescription": "Utilizzare l'accesso alla posizione per mantenere i download attivi in background", + "currentVersion": "Versione attuale {version}", + "contributors": "Contributori", + "viewContributors": "Visualizza i contributi", + "maxRunningDescription": "Numero massimo di attività che possono essere scaricate contemporaneamente", + "defaultUserAgentDescription": "User-Agent predefinito per download HTTP e HTTPS", + "listenPort": "Ascolta Port", + "onePerLine": "Una voce per linea", + "proxyProtocol": "Protocollo di proxy", + "restartRequired": "Questa impostazione ha effetto dopo il riavvio dell'app", + "socketPath": "Sentiero di presa", + "listenAddress": "Ascoltare Indirizzo", + "currentConnection": "Connessione corrente", + "webhookPush": "Webhook Endpoints", + "webhookPushDescription": "Inviare notifiche HTTP POST a questi endpoint quando le attività sono complete o non vengono eseguite", + "scriptExecution": "Scrittori", + "scriptExecutionDescription": "Eseguire questi script dopo un download completa con successo", + "addWebhook": "Aggiungi Webhook", + "editWebhook": "Modificare Webhook", + "addScript": "Aggiungi Script", + "editScript": "Modifica script", + "openDirectory": "Aprire la directory", + "browseFiles": "Sfoglia file", + "updateEveryDay": "Aggiornare ogni giorno", + "updateVersion": "Aggiornamento", + "checkForUpdates": "Controllare gli aggiornamenti", + "updating": "- Alzare...", + "colorGreen": "Verde", + "colorRed": "Rosso", + "colorRose": "Rosa", + "colorOrange": "Arancione", + "colorCyan": "Cyan", + "colorBlue": "Blu", + "colorAmber": "Ambra Amber", + "colorPurple": "Viola", + "noExtensions": "Nessuna estensione", + "searchExtensions": "Prolunghe di ricerca...", + "extensionDeveloperGuide": "Guida allo sviluppo di estensione", + "extensionInstallFromUrl": "Installare l'estensione dall'URL", + "unableOpenExtensionDocs": "Incapace di aprire la guida allo sviluppatore di estensione", + "removeExtensionTitle": "Disinstallare l'estensione?", + "removeExtensionConfirm": "Sei sicuro di voler disinstallare “{name}”?", + "uninstall": "Disinstallare", + "unableLoadExtensions": "Incapace di caricare le estensioni", + "filters": "Filtri", + "allTasks": "Tutte le attività", + "searchDownloads": "Cerca downloads...", + "searchHistory": "Cerca nella cronologia…", + "addTask": "Aggiungi attività", + "emptyTaskList": "Nessun compito in questa lista", + "noMatchingTasks": "Nessuna attività corrispondente trovata", + "taskList": "Elenco delle attività", + "selectedCount": "{selected} / {total} selezionato", + "unableOpenPath": "Impossibile aprire {path}", + "unableLocatePath": "Impossibile individuare {path}", + "clearSelection": "Selezione chiara", + "deselectTask": "Deseleziona", + "selectTask": "Seleziona", + "updateManually": "Aggiorna manualmente", + "stopListening": "Smettila di ascoltare", + "listenForUrl": "Ascoltare l'URL", + "waitingForReplacementUrl": "Attendere un URL di sostituzione", + "taskNotFound": "Compito non trovato", + "storagePath": "Sentiero di stoccaggio", + "directory": "Cartella", + "mode": "Modalità", + "keepOriginalName": "Mantenere il nome originale", + "enterCount": "Inserire il conteggio", + "chooseDownloadDirectory": "Scegli la directory download", + "applicationStorage": "Spazio di archiviazione dell'app", + "applicationStorageDescription": "Spazio affidabile dell'app; i file vengono rimossi quando l'app viene disinstallata", + "publicDownloadsDescription": "Conserva i file dopo la disinstallazione; la disponibilità dipende dal dispositivo Android", + "downloadsDirectoryUnavailable": "Gopeed non può scrivere in Download/Gopeed su questo dispositivo", + "enterValue": "Valore di ingresso", + "trackers": "Tracciatori", + "oneTrackerPerLine": "URL di un tracker per linea", + "pasteDownloadLinks": "Incolla i collegamenti HTTP, HTTPS o MAGNET o rilascia un file torrent qui", + "unableReadTorrent": "Impossibile leggere il file torrent: {error}", + "torrentOnly": "Solo i file .torrent sono supportati", + "torrentDataEmpty": "I dati dei file Torrent sono vuoti", + "torrentPathUnavailable": "Non è possibile accedere al percorso file torrent", + "selectAtLeastOneFile": "Selezionare almeno un file", + "resolvedFiles": "File recuperati", + "resolvedFileCount": "{count} risolti", + "resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile", + "noFiles": "Nessun file", + "createHistory": "Creare la storia", + "noHistory": "Nessuna storia", + "directDownloadDescription": "Salta la risoluzione e crea immediatamente i compiti", + "taskProgress": "Prospettive di attività", + "updateAndResume": "Aggiornamento e curriculum", + "httpHeaders": "Intestazioni HTTP", + "piece": "Pezzo {number}", + "pieceRange": "Pezzi {start}–{end}", + "connectionStatus": "Stato di connessione", + "connectionCount": "{count}", + "retrying": "Ripristino", + "connectionFailed": "Connessione non riuscita", + "connectionFailedRetries": "Connessione non riuscita dopo i tentativi {count}", + "retryingCount": "Nuovo tentativo · {count} riprova", + "pieceStatus": "Stato dei pezzi", + "peers": "Peer", + "seeders": "Seeder", + "leechers": "Leecher", + "downloadedAmount": "Scaricato", + "uploadedAmount": "Caricato", + "shareRatio": "Rapporto di condivisione", + "shareDuration": "Durata condivisione", + "connectedPeers": "{count} collegato", + "active": "AttivitÃ", + "completedPieces": "Piedi completi", + "activePeers": "{count} attivo", + "incomplete": "Incompleto", + "address": "Indirizzo", + "client": "Cliente", + "pieces": "Pezzi", + "source": "Fonte", + "fileRelevance": "Rilevanza dei file", + "taskDetails": "Dettagli del lavoro", + "taskDetailsInfoTab": "Dettagli", + "taskDetailsFilesTab": "File", + "taskDetailsStatsTab": "Statistiche", + "noStatistics": "Nessuna statistica disponibile", + "noPeers": "Nessuna connessione", + "deleteTasksTitle": "Eliminare le attività {count}?", + "keepFilesDescription": "Rimuovi solo il record dell'attività da Gopeed", + "readingRuntimeStatus": "Leggere lo stato di runtime...", + "apiConfigSaved": "Impostazioni API salvate. Riavviare l'app per applicarle", + "socketPathRequired": "Il percorso di presa è richiesto", + "serverRequired": "Il server è richiesto", + "portRangeError": "Inserisci una porta tra 0 e 65535", + "portInUseValue": "La porta {port} è già in uso", + "backgroundLocationPermissionRequired": "Permette sempre l'accesso alla posizione è necessario per i download di sfondo", + "scriptPath": "Percorso di script", + "logDirectoryUnavailable": "La directory di log non è inizializzata", + "unableOpenExternalLink": "Incapace di aprire il link esterno", + "neverUpdated": "Mai aggiornato", + "lastUpdated": "Ultimo aggiornamento: {time}", + "unableLoadSettings": "Non è possibile caricare le impostazioni", + "fieldRequired": "{field} è obbligatorio", + "addGithubMirror": "Aggiungi GitHub Specchio", + "editGithubMirror": "Modificare lo specchio GitHub", + "confirmDeleteTitle": "Confermare la cancellazione?", + "confirmDeleteNamed": "Sei sicuro di voler eliminare \"{name}\"?", + "categoryFieldsRequired": "Nome e percorso di categoria sono richiesti", + "addCategory": "Aggiungi la categoria", + "editCategory": "Modifica della categoria", + "deleteCategoryTitle": "Elimina categoria?", + "updateFailed": "Aggiornamento fallito. Si prega di riprovare più tardi", + "newVersionAvailable": "Una nuova versione è disponibile", + "releaseNotes": "Cosa c’è di nuovo", + "noReleaseNotes": "Nessun note di rilascio disponibili", + "preparingDownload": "Preparare il download...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Servizio in background Gopeed", + "androidForegroundNotificationTitle": "Gopeed è in esecuzione", + "androidForegroundNotificationText": "I download possono continuare in background" +} diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb new file mode 100644 index 00000000..c28c6ab0 --- /dev/null +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ja", + "label": "日本語", + "error": "エラー", + "tip": "ヒント", + "confirm": "確認", + "confirmDelete": "削除してもよろしいですか?", + "cancel": "キャンセル", + "on": "オンリー", + "off": "オフ", + "selectAll": "すべてを選択", + "select": "選択する", + "task": "タスク", + "downloading": "ダウンロード中", + "downloaded": "ダウンロード済", + "setting": "設定", + "donate": "寄付する", + "exit": "終了", + "pressBackAgainToExit": "もう一度戻るボタンを押すと終了します", + "create": "タスクを作成", + "directDownload": "直接ダウンロード", + "advancedOptions": "詳細オプション", + "followSettings": "設定に従う", + "downloadLink": "ダウンロードリンク", + "downloadLinkValid": "ダウンロードリンクを入力してください", + "downloadLinkHit": "ダウンロードリンクを1行に1つずつ入力してください", + "downloadLinkHitDesktop": "または、torrent ファイルを直接ここにドラッグします", + "download": "ダウンロード", + "noFileSelected": "続行するには少なくとも 1 つのファイルを選択してください", + "noStoragePermission": "ストレージのパーミッションが必要", + "selectFile": "ファイルを選択", + "rename": "保存時のファイル名", + "basic": "基本", + "advanced": "高度", + "general": "全般", + "downloadDir": "ダウンロードディレクトリ", + "rememberLastDownloadDir": "前回のタスク保存先を自動保存", + "downloadDirValid": "ダウンロードディレクトリを選択してください", + "connections": "接続", + "useServerCtime": "ファイル作成時にサーバー時間を使用する", + "maxRunning": "最大実行タスク", + "defaultDirectDownload": "デフォルトで直接ダウンロードにチェックを入れる", + "autoStartTasks": "起動時に未完了のタスクを自動的に開始", + "autoTorrentEnable": ".torrentファイルからBTタスクを自動作成", + "autoTorrentDeleteAfterDownload": "BTタスク作成後に.torrentファイルを削除", + "autoDeleteMissingFileTasks": "欠落ファイルタスクを自動削除", + "items": "{count} アイテム", + "subscribeTracker": "トラッカーを購読", + "subscribeFail": "登録に失敗しました。ネットワークを確認するか、後でもう一度お試しください", + "update": "アップデート", + "updateDaily": "毎日アップデート", + "lastUpdate": "最終アップデート: @time", + "addTracker": "トラッカーを追加", + "addTrackerHit": "トラッカーサーバーの URL を 1 行に 1 つずつ入力してください", + "ui": "ツイート", + "theme": "テーマ", + "themeSystem": "システム", + "themeLight": "ライト", + "themeDark": "ダーク", + "locale": "言語", + "notifyWhenNewVersion": "アップデートを通知", + "analyticsEnabled": "統計状況のアップロード", + "analyticsEnabledDesc": "匿名の統計状況データを共有して改善に役立てる", + "about": "概要", + "homepage": "ホームページ", + "version": "バージョン", + "protocol": "プロトコル", + "port": "ポート", + "apiToken": "API トークン", + "notSet": "未設定", + "set": "設定", + "portInUse": "ポート[@port]は使用中のポートです。ポートを変更してください", + "effectAfterRestart": "設定は再起動後に反映されます", + "developer": "開発者", + "logDirectory": "ログディレクトリ", + "webhook": "ウェビオック", + "webhookEnable": "Webhookを有効にする", + "webhookDesc": "タスクが完了または失敗したときにHTTP POST通知を送信", + "webhookUrlHint": "ウェビホックURLを入力してください", + "webhookTest": "試験結果", + "webhookTestSuccess": "Webhookテスト成功", + "webhookTestFail": "Webhookテスト失敗", + "script": "スクリプト", + "scriptEnable": "スクリプトを有効にする", + "scriptDesc": "ダウンロードが完了したときにカスタムスクリプトを実行します", + "scriptPathHint": "スクリプトファイルパス(/path/to/script.sh など)を入力します", + "urlInvalid": "有効なHTTPまたはHTTPS URLを入力してください", + "required": "必須項目", + "show": "ショー", + "continueAction": "続行", + "pause": "パーユース", + "startAll": "すべてを開始", + "pauseAll": "すべてを一時停止", + "deleteTask": "@count タスクを削除", + "deleteTaskTip": "ダウンロードしたファイルを保持", + "delete": "削除", + "add": "追加", + "edit": "編集", + "newVersionTitle": "新しいバージョン @version を発見する", + "newVersionUpdate": "アップデート", + "newVersionLater": "後で", + "extensions": "拡張機能", + "extensionInstallUrl": "インストールURL", + "extensionInstallSuccess": "正常にインストールされました", + "extensionUpdateSuccess": "正常に更新されました", + "extensionDelete": "拡張機能を削除", + "extensionAlreadyLatest": "既に最新バージョンです", + "extensionFind": "拡張機能を探す", + "extensionDevelop": "拡張機能の開発", + "extensionStoreTitle": "エクステンションストア", + "extensionStoreSection": "ストアエクステンション", + "extensionInstalledSection": "インストール済みの拡張機能", + "extensionInstalledEmpty": "拡張機能はまだインストールされていません", + "extensionStoreEmpty": "一致する拡張機能が見つかりませんでした", + "extensionSearchHint": "名前、説明、作者名で検索", + "extensionSortStars": "ほとんどの星", + "extensionSortInstalls": "ほとんどのインストール", + "extensionSortUpdated": "最近更新された", + "extensionSortToggle": "並べ替え方向の切り替え", + "extensionLoadMore": "更に読み込む", + "extensionNoMore": "これ以上の拡張機能はありません", + "extensionInstalled": "インストール済み", + "extensionCanUpdate": "更新が利用可能", + "extensionInstall": "インストール", + "extensionLoadLocal": "ローカルからインストール", + "extensionFilterAll": "全て", + "extensionFilterMarket": "マーケット", + "extensionFilterInstalled": "インストール済み", + "extensionManualInstall": "手動インストール", + "extensionInstallTools": "インストールオプション", + "history": "履歴", + "clearHistory": "履歴を消去", + "noHistoryFound": "履歴が見つかりません", + "serviceTitle": "サービスのダウンロード", + "serviceText": "ランニング", + "network": "通信ネットワーク", + "proxy": "プロキシ", + "noProxy": "プロキシなし", + "systemProxy": "システムプロキシ", + "customProxy": "カスタムプロキシ", + "server": "サーバ", + "username": "ユーザ名", + "password": "パスワード", + "thanks": "感謝", + "thanksDesc": "Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します!", + "browserExtension": "ブラウザ拡張機能", + "launchAtStartup": "起動時に起動", + "runAsMenubarApp": "メニューバーアプリとして実行", + "runAsMenubarAppDesc": "Dockアイコンを非表示にして、メニューバーのみで実行", + "seedConfig": "シード設定", + "seedKeep": "手動で停止するまでシードを継続する", + "seedRatio": "シード共有比", + "seedTime": "シード時間 (分)", + "setAsDefaultBtClient": "デフォルトのBTクライアントとして設定", + "taskDetail": "タスク詳細", + "taskName": "タスク名", + "taskUrl": "タスク URL", + "downloadPath": "パスをダウンロード", + "skipVerifyCert": "証明書の検証をスキップ", + "archives": "アーカイブ", + "autoExtract": "アーカイブを自動展開", + "archivePassword": "アーカイブパスワード", + "archivePasswordHint": "パスワードなしの場合は空のままにしてください", + "deleteAfterExtract": "展開後にアーカイブを削除", + "extracting": "展開中", + "extractDone": "展開完了", + "extractError": "展開失敗", + "waitingParts": "分割ファイルを待っています", + "name": "お名前(必須)", + "size": "寸法:", + "unknown": "未知の", + "fileSelectedCount": "ファイル:", + "fileSelectedSize": "サイズ:", + "httpHeader": "ヘッダー", + "httpHeaderName": "ヘッダー名", + "httpHeaderValue": "ヘッダー値", + "login": "サインイン", + "username_required": "ユーザー名を入力してください", + "password_required": "パスワードをご入力ください", + "login_success": "ログイン 成功", + "login_failed": "ログイン失敗、ユーザー名とパスワードの確認", + "login_failed_network": "ログイン失敗、ネットワーク接続の確認", + "insertPlaceholder": "プレースホルダーを挿入", + "placeholderYear": "現在の年", + "placeholderMonth": "現在の月 (01-12)", + "placeholderDay": "現在の日 (01-31)", + "placeholderDate": "完全な日付 (年-月-日)", + "example": "例: @value", + "downloadCategories": "ダウンロードカテゴリ", + "categoryMusic": "音楽", + "categoryVideo": "動画", + "categoryDocument": "ドキュメント", + "categoryProgram": "プログラム", + "categoryName": "カテゴリ名", + "categoryPath": "カテゴリパス", + "builtInCategory": "内蔵カテゴリは削除できません", + "selectCategory": "カテゴリを選択", + "quickDirectories": "クイックフォルダー", + "githubMirror": "GitHub ミラー", + "githubMirrorEnable": "GitHub Mirrorを有効にする", + "githubMirrorDesc": "ミラーを使用して、GitHubコンテンツのダウンロードを加速", + "githubMirrorType": "ミラーのタイプ", + "githubMirrorUrl": "ミラー URL", + "githubMirrorUrlHint": "ミラー URL を入力", + "updateUrl": "URLの更新", + "updateUrlManual": "マニュアル更新", + "updateUrlListen": "最新情報を聴く", + "updateUrlListeningTip": "URL更新待ち", + "updateUrlCancelListen": "キャンセル", + "updateUrlDialogHint": "ダウンロードURLを新規登録", + "pendingUpdateFound": "終了更新タスクが見つかった", + "pendingUpdateConfirm": "URL更新を待ち受ける「{name}」のタスク。新しいURLで更新したいですか?", + "pendingUpdateYes": "タスクの更新", + "pendingUpdateNo": "新規タスクの作成", + "ed2k": "ED2Kの特長", + "ed2kTcpPort": "TCP リスニングポート", + "ed2kUdpPort": "UDP リスニングポート", + "ed2kServerList": "サーバーアドレス", + "ed2kServerMet": "Server.met ソース", + "ed2kNodesDat": "nodes.dat Sources の使い方", + "ed2kAutoPort": "自動割り当て", + "ed2kOnePerLine": "1ラインあたりの1つの記入項目", + "ed2kServersHint": "host:port, 1行につき1行", + "ed2kServerMetHint": "Server.met URL または ed2k のサーバーリスト リンク、1 行につき 1 行", + "ed2kNodesDatHint": "nodes.dat パスまたは URL の 1 行につき 1 行", + "desktopNotification": "デスクトップ通知", + "backgroundLocationKeepAlive": "背景ダウンロードは生き続ける", + "notificationTaskDone": "タスク完了", + "notificationTaskError": "タスクエラー", + "appTitle": "Gopeed ダウンロードマネージャー", + "unsupportedWindowPayload": "サポートされていないウィンドウ要求", + "runtimeInitializationFailed": "Gopeed ランタイムを初期化できません。\n{error}", + "save": "保存する", + "retry": "再試行", + "close": "おすすめ", + "back": "戻る", + "open": "営業部", + "openFile": "ファイルを開く", + "share": "共有", + "copy": "コピー", + "copied": "コピー", + "refresh": "リフレッシュ", + "status": "ステータス", + "createdAt": "作成日時", + "speed": "スピード", + "uploadSpeed": "アップロード速度", + "remaining": "メンテナンス", + "downloadDuration": "ダウンロード時間", + "progress": "開発実績", + "failed": "失敗した", + "completed": "完了しました", + "waiting": "待望の待ち合わせ", + "taskFailed": "タスクが失敗しました", + "unknownSize": "未知のサイズ", + "secondsRemaining": "{count}秒残っている", + "minutesRemaining": "{count} 残り分", + "hoursRemaining": "{count} 時間残っている", + "secondsDuration": "{count} 秒", + "minutesDuration": "{count} 分", + "hoursDuration": "{count} 時間", + "followSystem": "システムフォロー", + "basicSettings": "基本設定", + "downloadSettings": "ダウンロード設定", + "advancedSettings": "高度な設定", + "themeColor": "テーマカラー", + "themeColorDescription": "アプリのアクセントカラーをカスタマイズする", + "browserExtensionDescription": "インストール後にGopeedに直接ブラウザのダウンロードを送信", + "desktopNotificationDescription": "タスクが完了したり失敗したときにシステム通知を表示する", + "backgroundLocationKeepAliveDescription": "背景でダウンロードをアクティブに保つために、場所へのアクセスを使用する", + "currentVersion": "現在のバージョン{version}", + "contributors": "参加者", + "viewContributors": "貢献者を見る", + "maxRunningDescription": "同時にダウンロードできるタスクの最大数", + "defaultUserAgentDescription": "HTTP および HTTPS のダウンロードのためのデフォルトユーザーエージェント", + "listenPort": "リスニングポート", + "onePerLine": "1ラインあたりの1つの記入項目", + "proxyProtocol": "プロキシプロトコル", + "restartRequired": "この設定はアプリを再起動した後に有効になります", + "socketPath": "ソケットパス", + "listenAddress": "アドレスを聴く", + "currentConnection": "現在の関係", + "webhookPush": "Webhookエンドポイント", + "webhookPushDescription": "タスクが完了または失敗したときに、HTTP POST 通知をエンドポイントに送信します", + "scriptExecution": "スクリプト", + "scriptExecutionDescription": "ダウンロードが完了した後、これらのスクリプトを実行します", + "addWebhook": "Webhook を追加", + "editWebhook": "Webhook 編集", + "addScript": "スクリプトを追加", + "editScript": "スクリプトを編集する", + "openDirectory": "ディレクトリを開く", + "browseFiles": "ファイルを参照", + "updateEveryDay": "日々更新", + "updateVersion": "更新情報", + "checkForUpdates": "更新情報をチェック", + "updating": "アップデーティング...", + "colorGreen": "緑が緑", + "colorRed": "レッド", + "colorRose": "ローズ", + "colorOrange": "オレンジ", + "colorCyan": "チャイニーズ", + "colorBlue": "青", + "colorAmber": "アナバー", + "colorPurple": "パープル", + "noExtensions": "延長なし", + "searchExtensions": "拡張機能の検索", + "extensionDeveloperGuide": "エクステンション開発者ガイド", + "extensionInstallFromUrl": "URLから拡張機能をインストールする", + "unableOpenExtensionDocs": "拡張開発者ガイドを開くことができません", + "removeExtensionTitle": "拡張機能をアンインストールする?", + "removeExtensionConfirm": "「{name}」をアンインストールしたいですか?", + "uninstall": "アンインストール", + "unableLoadExtensions": "拡張機能を読み込むことができない", + "filters": "フィルター", + "allTasks": "すべてのタスク", + "searchDownloads": "ダウンロード検索...", + "searchHistory": "履歴を検索…", + "addTask": "タスクの追加", + "emptyTaskList": "このリストのタスクはなし", + "noMatchingTasks": "マッチングタスクが見つかりません", + "taskList": "タスクリスト", + "selectedCount": "{selected} / {total} 選択", + "unableOpenPath": "開くことのできない {path}", + "unableLocatePath": "見つけることができません。 {path}", + "clearSelection": "クリアな選択", + "deselectTask": "選択解除", + "selectTask": "選択", + "updateManually": "手動で更新する", + "stopListening": "聴診停止", + "listenForUrl": "URL を listen する", + "waitingForReplacementUrl": "交換URLの待ち合わせ", + "taskNotFound": "タスクが見つかりません", + "storagePath": "貯蔵のパス", + "directory": "ディレクトリ", + "mode": "モード", + "keepOriginalName": "元の名前を保って下さい", + "enterCount": "カウントを入力", + "chooseDownloadDirectory": "ダウンロードディレクトリを選択", + "applicationStorage": "アプリストレージ", + "applicationStorageDescription": "安定して利用できるアプリストレージです。アプリをアンインストールするとファイルも削除されます", + "publicDownloadsDescription": "アンインストール後もファイルを保持します。利用可否は Android 端末によって異なります", + "downloadsDirectoryUnavailable": "この端末では Gopeed から Download/Gopeed に書き込めません", + "enterValue": "値を入力してください", + "trackers": "トラッカー", + "oneTrackerPerLine": "1行あたりの1つの追跡者URL", + "pasteDownloadLinks": "HTTP、HTTPS、またはMAGNETリンクを貼り付け、または、ここにトレントファイルをドロップします", + "unableReadTorrent": "トレントファイルを読むことができない:{error}", + "torrentOnly": ".torrent ファイルのみがサポートされています", + "torrentDataEmpty": "トレントファイルデータが空です", + "torrentPathUnavailable": "トレントファイルパスにアクセスできない", + "selectAtLeastOneFile": "少なくとも1つのファイルを選択します", + "resolvedFiles": "解決されたファイル", + "resolvedFileCount": "{count} ファイルが解決しました", + "resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません", + "noFiles": "ファイルがない場合", + "createHistory": "歴史を創造する", + "noHistory": "歴史なし", + "directDownloadDescription": "解決をスキップし、すぐにタスクを作成", + "taskProgress": "タスクの進捗", + "updateAndResume": "更新履歴と履歴書", + "httpHeaders": "HTTPヘッダー", + "piece": "ピース {number}", + "pieceRange": "ピース {start}–{end}", + "connectionStatus": "接続状況", + "connectionCount": "{count} 接続", + "retrying": "再試行", + "connectionFailed": "接続が失敗しました", + "connectionFailedRetries": "{count} 回再試行した後、接続に失敗しました", + "retryingCount": "再試行中 · {count} 回", + "pieceStatus": "ピースステータス", + "peers": "ピーラー", + "seeders": "シード", + "leechers": "リーチャーズ", + "downloadedAmount": "ダウンロード", + "uploadedAmount": "アップロード", + "shareRatio": "共有比", + "shareDuration": "共有時間", + "connectedPeers": "接続されている{count}", + "active": "アクティブ", + "completedPieces": "完成品", + "activePeers": "{count} アクティブ", + "incomplete": "不完全な", + "address": "郵便番号", + "client": "クライアント", + "pieces": "アクセサリー", + "source": "ソース", + "fileRelevance": "ファイル関連度", + "taskDetails": "タスクの詳細", + "taskDetailsInfoTab": "インフォメーション", + "taskDetailsFilesTab": "ファイルのファイル", + "taskDetailsStatsTab": "統計情報", + "noStatistics": "統計情報なし", + "noPeers": "接続なし", + "deleteTasksTitle": "{count}タスクを削除しますか?", + "keepFilesDescription": "Gopeed からタスクレコードを削除するだけです", + "readingRuntimeStatus": "実行時間ステータスを読み込みます...", + "apiConfigSaved": "API の設定を保存します。アプリを再起動して、それらを適用します", + "socketPathRequired": "ソケットパスが必要です", + "serverRequired": "サーバが必要です", + "portRangeError": "0と65535の間のポートを入力してください", + "portInUseValue": "ポート{port}は既に使用中です", + "backgroundLocationPermissionRequired": "背景ダウンロードには必ず位置情報アクセスを許可する", + "scriptPath": "スクリプトパス", + "logDirectoryUnavailable": "ログディレクトリは初期化されません", + "unableOpenExternalLink": "外部リンクを開くことができない", + "neverUpdated": "更新されることはありません", + "lastUpdated": "最近更新: {time}", + "unableLoadSettings": "設定をロードできない", + "fieldRequired": "{field} が必要です", + "addGithubMirror": "GitHub ミラーを追加", + "editGithubMirror": "GitHub Mirror を編集", + "confirmDeleteTitle": "削除の確認", + "confirmDeleteNamed": "「{name}」を削除したいですか?", + "categoryFieldsRequired": "所属名とパスが必要です", + "addCategory": "カテゴリを追加", + "editCategory": "編集カテゴリ", + "deleteCategoryTitle": "カテゴリを削除?", + "updateFailed": "更新が失敗しました。 後でもう一度お試しください", + "newVersionAvailable": "新規バージョンが利用可能", + "releaseNotes": "新着情報", + "noReleaseNotes": "リリースノートは使用できません", + "preparingDownload": "ダウンロードの準備...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed バックグラウンドサービス", + "androidForegroundNotificationTitle": "Gopeed は実行中です", + "androidForegroundNotificationText": "バックグラウンドでダウンロードを続行できます" +} diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb new file mode 100644 index 00000000..722f96ed --- /dev/null +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ko", + "label": "한국어", + "error": "오류", + "tip": "안내", + "confirm": "확인", + "confirmDelete": "정말 삭제하시겠습니까?", + "cancel": "취소", + "on": "켜짐", + "off": "꺼짐", + "selectAll": "전체 선택", + "select": "선택", + "task": "작업", + "downloading": "다운로드 중", + "downloaded": "다운로드 완료", + "setting": "설정", + "donate": "후원", + "exit": "종료", + "pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요", + "create": "작업 생성", + "directDownload": "바로 다운로드", + "advancedOptions": "고급 옵션", + "followSettings": "설정값 사용", + "downloadLink": "다운로드 링크", + "downloadLinkValid": "다운로드 링크를 입력해 주세요", + "downloadLinkHit": "다운로드 링크를 한 줄에 하나씩 입력해 주세요@append", + "downloadLinkHitDesktop": ", 또는 토렌트 파일을 이곳에 직접 끌어다 놓으세요", + "download": "다운로드", + "noFileSelected": "계속하려면 파일을 하나 이상 선택해 주세요", + "noStoragePermission": "저장소 권한이 필요합니다", + "selectFile": "파일 선택", + "rename": "이름 바꾸기", + "basic": "기본", + "advanced": "고급", + "general": "일반", + "downloadDir": "다운로드 폴더", + "rememberLastDownloadDir": "마지막 작업 디렉터리 자동 저장", + "downloadDirValid": "다운로드 폴더를 선택해 주세요", + "connections": "연결 수", + "useServerCtime": "파일 생성 시간에 서버 시간 사용", + "maxRunning": "최대 동시 작업 수", + "defaultDirectDownload": "기본으로 바로 다운로드 사용", + "autoStartTasks": "시작 시 미완료 작업 자동 시작", + "autoTorrentEnable": ".torrent 파일로 BT 작업 자동 생성", + "autoTorrentDeleteAfterDownload": "BT 작업 생성 후 .torrent 파일 삭제", + "autoDeleteMissingFileTasks": "파일이 없는 작업 자동 삭제", + "items": "{count}개 항목", + "subscribeTracker": "트래커 구독", + "subscribeFail": "구독에 실패했습니다. 네트워크를 확인하거나 나중에 다시 시도해 주세요", + "update": "업데이트", + "updateDaily": "매일 업데이트", + "lastUpdate": "마지막 업데이트: @time", + "addTracker": "트래커 추가", + "addTrackerHit": "트래커 서버 주소를 한 줄에 하나씩 입력해 주세요", + "ui": "UI", + "theme": "테마", + "themeSystem": "시스템", + "themeLight": "라이트", + "themeDark": "다크", + "locale": "언어", + "notifyWhenNewVersion": "새 버전 알림", + "analyticsEnabled": "통계 업로드", + "analyticsEnabledDesc": "익명 사용 데이터를 공유하여 개선에 도움을 주세요", + "about": "정보", + "homepage": "홈페이지", + "version": "버전", + "protocol": "프로토콜", + "port": "포트", + "apiToken": "API 토큰", + "notSet": "미설정", + "set": "설정됨", + "portInUse": "포트 [@port]가 이미 사용 중입니다. 다른 포트로 변경해 주세요", + "effectAfterRestart": "재시작 후 적용됩니다", + "developer": "개발자", + "logDirectory": "로그 폴더", + "webhook": "웹훅", + "webhookEnable": "웹훅 사용", + "webhookDesc": "작업이 완료되거나 실패하면 HTTP POST 알림 전송", + "webhookUrlHint": "웹훅 URL을 입력하세요", + "webhookTest": "테스트", + "webhookTestSuccess": "웹훅 테스트에 성공했습니다", + "webhookTestFail": "웹훅 테스트에 실패했습니다", + "script": "스크립트", + "scriptEnable": "스크립트 사용", + "scriptDesc": "다운로드가 성공적으로 완료되면 사용자 지정 스크립트 실행", + "scriptPathHint": "스크립트 파일 경로 입력 (예: /path/to/script.sh)", + "urlInvalid": "올바른 HTTP 또는 HTTPS URL을 입력해 주세요", + "required": "필수 입력 항목입니다", + "show": "보기", + "continueAction": "계속", + "pause": "일시정지", + "startAll": "전체 시작", + "pauseAll": "전체 일시정지", + "deleteTask": "작업 @count개 삭제", + "deleteTaskTip": "다운로드된 파일은 유지", + "delete": "삭제", + "add": "추가", + "edit": "편집", + "newVersionTitle": "새 버전 @version 발견", + "newVersionUpdate": "지금 업데이트", + "newVersionLater": "나중에", + "extensions": "확장 프로그램", + "extensionInstallUrl": "설치 URL", + "extensionInstallSuccess": "설치되었습니다", + "extensionUpdateSuccess": "업데이트되었습니다", + "extensionDelete": "확장 프로그램 삭제", + "extensionAlreadyLatest": "이미 최신 버전입니다", + "extensionFind": "확장 프로그램 찾기", + "extensionDevelop": "확장 프로그램 개발", + "extensionStoreTitle": "확장 프로그램 스토어", + "extensionStoreSection": "스토어 확장 프로그램", + "extensionInstalledSection": "설치된 확장 프로그램", + "extensionInstalledEmpty": "설치된 확장 프로그램이 없습니다", + "extensionStoreEmpty": "일치하는 확장 프로그램이 없습니다", + "extensionSearchHint": "이름, 제목, 설명 또는 작성자로 검색", + "extensionSortStars": "별점 높은 순", + "extensionSortInstalls": "설치 많은 순", + "extensionSortUpdated": "최근 업데이트 순", + "extensionSortToggle": "정렬 방향 전환", + "extensionLoadMore": "더 보기", + "extensionNoMore": "더 이상 없습니다", + "extensionInstalled": "설치됨", + "extensionCanUpdate": "업데이트 가능", + "extensionInstall": "설치", + "extensionLoadLocal": "로컬 폴더 설치", + "extensionFilterAll": "전체", + "extensionFilterMarket": "스토어", + "extensionFilterInstalled": "설치됨", + "extensionManualInstall": "수동 설치", + "extensionInstallTools": "설치 옵션", + "history": "기록", + "clearHistory": "기록 지우기", + "noHistoryFound": "기록이 없습니다", + "serviceTitle": "다운로드 서비스", + "serviceText": "실행 중", + "network": "네트워크", + "proxy": "프록시", + "noProxy": "프록시 사용 안 함", + "systemProxy": "시스템 프록시", + "customProxy": "사용자 지정 프록시", + "server": "서버", + "username": "사용자 이름", + "password": "비밀번호", + "thanks": "감사의 말", + "thanksDesc": "Gopeed 커뮤니티를 만들고 발전시키는 데 도움을 주신 모든 기여자분들께 감사드립니다!", + "browserExtension": "브라우저 확장 프로그램", + "launchAtStartup": "시작 시 자동 실행", + "runAsMenubarApp": "메뉴바 앱으로 실행", + "runAsMenubarAppDesc": "독 아이콘을 숨기고 메뉴바에서만 실행", + "seedConfig": "시드 설정", + "seedKeep": "수동으로 중지할 때까지 계속 시드", + "seedRatio": "시드 비율", + "seedTime": "시드 시간(분)", + "setAsDefaultBtClient": "기본 BT 클라이언트로 설정", + "taskDetail": "작업 상세 정보", + "taskName": "작업 이름", + "taskUrl": "작업 URL", + "downloadPath": "다운로드 경로", + "skipVerifyCert": "인증서 확인 건너뛰기", + "archives": "압축 파일", + "autoExtract": "압축 파일 자동 압축 해제", + "archivePassword": "압축 파일 비밀번호", + "archivePasswordHint": "비밀번호가 없으면 비워 두세요", + "deleteAfterExtract": "압축 해제 후 압축 파일 삭제", + "extracting": "압축 해제 중", + "extractDone": "압축 해제 완료", + "extractError": "압축 해제 실패", + "waitingParts": "분할 파일 대기 중", + "name": "이름", + "size": "크기", + "unknown": "알 수 없음", + "fileSelectedCount": "파일: ", + "fileSelectedSize": "크기: ", + "httpHeader": "헤더", + "httpHeaderName": "헤더 이름", + "httpHeaderValue": "헤더 값", + "login": "로그인", + "username_required": "사용자 이름을 입력해 주세요", + "password_required": "비밀번호를 입력해 주세요", + "login_success": "로그인에 성공했습니다", + "login_failed": "로그인에 실패했습니다. 사용자 이름과 비밀번호를 확인해 주세요", + "login_failed_network": "로그인에 실패했습니다. 네트워크 연결을 확인해 주세요", + "insertPlaceholder": "플레이스홀더 삽입", + "placeholderYear": "현재 연도", + "placeholderMonth": "현재 월(01-12)", + "placeholderDay": "현재 일(01-31)", + "placeholderDate": "전체 날짜(YYYY-MM-DD)", + "example": "예: @value", + "downloadCategories": "다운로드 카테고리", + "categoryMusic": "음악", + "categoryVideo": "동영상", + "categoryDocument": "문서", + "categoryProgram": "프로그램", + "categoryName": "카테고리 이름", + "categoryPath": "카테고리 경로", + "builtInCategory": "기본 제공 카테고리는 삭제할 수 없습니다", + "selectCategory": "카테고리 선택", + "quickDirectories": "빠른 폴더", + "githubMirror": "GitHub 미러", + "githubMirrorEnable": "GitHub 미러 사용", + "githubMirrorDesc": "미러를 사용하여 GitHub 콘텐츠 다운로드 가속화", + "githubMirrorType": "미러 유형", + "githubMirrorUrl": "미러 URL", + "githubMirrorUrlHint": "미러 URL을 입력하세요", + "updateUrl": "업데이트 URL", + "updateUrlManual": "수동 업데이트", + "updateUrlListen": "업데이트 감지", + "updateUrlListeningTip": "URL 업데이트를 기다리는 중", + "updateUrlCancelListen": "감지 취소", + "updateUrlDialogHint": "새 다운로드 URL을 입력하세요", + "pendingUpdateFound": "대기 중인 업데이트 작업 발견", + "pendingUpdateConfirm": "작업 \"{name}\"이(가) URL 업데이트를 기다리고 있습니다. 새 URL로 업데이트하시겠습니까?", + "pendingUpdateYes": "작업 업데이트", + "pendingUpdateNo": "새 작업 생성", + "ed2k": "사이트맵", + "ed2kTcpPort": "TCP 수신 포트", + "ed2kUdpPort": "UDP 수신 포트", + "ed2kServerList": "서버 주소", + "ed2kServerMet": "Server.met 소스", + "ed2kNodesDat": "nodes.dat 소스", + "ed2kAutoPort": "자동 할당", + "ed2kOnePerLine": "한 줄에 하나씩 입력", + "ed2kServersHint": "host:port 형식, 한 줄에 하나씩", + "ed2kServerMetHint": "Server.met URL 또는 ed2k 서버 목록 링크, 한 줄에 하나씩", + "ed2kNodesDatHint": "nodes.dat 경로 또는 URL, 한 줄에 하나씩", + "desktopNotification": "데스크톱 알림", + "backgroundLocationKeepAlive": "백그라운드 다운로드 유지", + "notificationTaskDone": "작업 완료", + "notificationTaskError": "작업 오류", + "appTitle": "Gopeed 다운로드 관리자", + "unsupportedWindowPayload": "지원 창 요청", + "runtimeInitializationFailed": "Gopeed 런타임을 초기화할 수 없습니다.\n{error}", + "save": "저장", + "retry": "의령", + "close": "뚱 베어", + "back": "뒤로", + "open": "열기", + "openFile": "파일 열기", + "share": "공유", + "copy": "복사", + "copied": "뚱 베어", + "refresh": "의붓기", + "status": "상태", + "createdAt": "생성 시간", + "speed": "속도", + "uploadSpeed": "연결 속도", + "remaining": "의논하기", + "downloadDuration": "다운로드 소요 시간", + "progress": "의 특징", + "failed": "뚱 베어", + "completed": "의제휴", + "waiting": "대기 중", + "taskFailed": "작업 실패", + "unknownSize": "관련 상품", + "secondsRemaining": "{count} 초 남아있는", + "minutesRemaining": "{count} 분 남아있는", + "hoursRemaining": "{count}시간 남아있는", + "secondsDuration": "{count}초", + "minutesDuration": "{count}분", + "hoursDuration": "{count}시간", + "followSystem": "시스템 기본값", + "basicSettings": "기본 설정", + "downloadSettings": "다운로드 설정", + "advancedSettings": "고급 설정", + "themeColor": "테마 색상", + "themeColorDescription": "앱 악센트 색상을 사용자 정의", + "browserExtensionDescription": "브라우저를 설치 후 Gopeed에 직접 다운로드", + "desktopNotificationDescription": "작업이 완료되거나 실패할 때 시스템 알림 표시", + "backgroundLocationKeepAliveDescription": "배경에서 다운로드를 계속하려면 위치 액세스", + "currentVersion": "현재 버전 {version}", + "contributors": "기여자", + "viewContributors": "기여자보기", + "maxRunningDescription": "동시에 다운로드 할 수있는 작업의 최대 수", + "defaultUserAgentDescription": "HTTP 및 HTTPS 다운로드의 기본 User-Agent", + "listenPort": "수신 포트", + "onePerLine": "1 회선 당 1 회", + "proxyProtocol": "프록시 프로토콜", + "restartRequired": "이 설정은 앱을 다시 시작 후 효과가 걸립니다", + "socketPath": "소켓 경로", + "listenAddress": "의논문", + "currentConnection": "현재 연결", + "webhookPush": "Webhook 엔드포인트", + "webhookPushDescription": "작업을 완료하거나 실패할 때 HTTP POST 알림을 보내", + "scriptExecution": "스크립트", + "scriptExecutionDescription": "다운로드가 완료된 후이 스크립트를 실행", + "addWebhook": "Webhook 추가", + "editWebhook": "Webhook에 대한 의견", + "addScript": "Script 추가", + "editScript": "Script 편집", + "openDirectory": "관련 링크", + "browseFiles": "파일 찾아보기", + "updateEveryDay": "매일 업데이트", + "updateVersion": "업데이트", + "checkForUpdates": "자주 묻는 질문", + "updating": "의욕탕", + "colorGreen": "초록색", + "colorRed": "은", + "colorRose": "으로", + "colorOrange": "담당자: Mr. s", + "colorCyan": "담당자: Mr. wang", + "colorBlue": "파란색", + "colorAmber": "뚱 베어", + "colorPurple": "은밀한", + "noExtensions": "확장 없음", + "searchExtensions": "의 확장 기능 ...", + "extensionDeveloperGuide": "확장 개발자 가이드", + "extensionInstallFromUrl": "URL에서 확장 설치", + "unableOpenExtensionDocs": "확장 개발자 가이드를 열 수 없습니다", + "removeExtensionTitle": "확장 제거?", + "removeExtensionConfirm": "당신은 \"{name}\"을 제거하고 싶지 않습니까?", + "uninstall": "Uninstaller를 제거", + "unableLoadExtensions": "확장을 로드할 수 없습니다", + "filters": "필터링", + "allTasks": "모든 작업", + "searchDownloads": "다운로드 수...", + "searchHistory": "기록 검색…", + "addTask": "작업 추가", + "emptyTaskList": "이 목록에 작업이 없습니다", + "noMatchingTasks": "일치하는 작업이 발견되지 않았습니다", + "taskList": "작업 목록", + "selectedCount": "{selected} / {total} 선택", + "unableOpenPath": "{path}을 열 수 없습니다", + "unableLocatePath": "{path}를 찾을 수 없습니다", + "clearSelection": "선택 사항", + "deselectTask": "선택 해제", + "selectTask": "선택", + "updateManually": "수동으로 업데이트", + "stopListening": "청취를 받으려면", + "listenForUrl": "URL을 들어", + "waitingForReplacementUrl": "대체 URL을 기다리기", + "taskNotFound": "찾을 수 없음", + "storagePath": "저장 경로", + "directory": "의논하기", + "mode": "* 이름", + "keepOriginalName": "이름 유지", + "enterCount": "의논문", + "chooseDownloadDirectory": "다운로드 디렉토리", + "applicationStorage": "앱 저장소", + "applicationStorageDescription": "안정적인 앱 저장소이며 앱을 제거하면 파일도 삭제됩니다", + "publicDownloadsDescription": "앱 제거 후에도 파일을 유지하며 사용 가능 여부는 Android 기기에 따라 다릅니다", + "downloadsDirectoryUnavailable": "이 기기에서는 Gopeed가 Download/Gopeed에 쓸 수 없습니다", + "enterValue": "계정 만들기", + "trackers": "트래커", + "oneTrackerPerLine": "선 당 1개의 추적자 URL", + "pasteDownloadLinks": "Paste HTTP, HTTPS, 또는 MAGNET 링크, 또는 여기에 급류 파일을 드롭", + "unableReadTorrent": "다운로드: {error}", + "torrentOnly": ".torrent 파일만 지원됩니다", + "torrentDataEmpty": "Torrent 파일 데이터가 비어 있습니다", + "torrentPathUnavailable": "토렌트 파일 경로에 액세스 할 수 없습니다", + "selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오", + "resolvedFiles": "해결된 파일", + "resolvedFileCount": "{count} 파일 해결", + "resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다", + "noFiles": "파일 없음", + "createHistory": "회사연혁", + "noHistory": "의 역사", + "directDownloadDescription": "재해를 드리며 즉시 업무를 만듭니다", + "taskProgress": "작업 진행", + "updateAndResume": "업데이트 및 재시작", + "httpHeaders": "HTTP 헤더", + "piece": "조각 {number}", + "pieceRange": "조각 {start}-{end}", + "connectionStatus": "연결 상태", + "connectionCount": "{count} 연결", + "retrying": "의령", + "connectionFailed": "연결 실패", + "connectionFailedRetries": "{count}회 재시도 후 연결에 실패했습니다", + "retryingCount": "재시도 중 · {count}회", + "pieceStatus": "조각 상태", + "peers": "팟캐스트", + "seeders": "의붓기", + "leechers": "의원", + "downloadedAmount": "다운로드", + "uploadedAmount": "업로드량", + "shareRatio": "공유 비율", + "shareDuration": "공유 시간", + "connectedPeers": "{count} 연결", + "active": "활성", + "completedPieces": "완료된 조각", + "activePeers": "{count} 활동", + "incomplete": "의 불완전한", + "address": "주소", + "client": "고객 지원", + "pieces": "조각", + "source": "소스", + "fileRelevance": "파일 연관도", + "taskDetails": "작업 세부 사항", + "taskDetailsInfoTab": "세부 정보", + "taskDetailsFilesTab": "파일", + "taskDetailsStatsTab": "의정부", + "noStatistics": "통계 수", + "noPeers": "연결 없음", + "deleteTasksTitle": "{count} 작업을 삭제?", + "keepFilesDescription": "Gopeed에서 작업 기록을 제거합니다", + "readingRuntimeStatus": "읽기 런타임 상태 ...", + "apiConfigSaved": "API 설정 저장. 앱을 다시 실행하여 적용할 수 있습니다", + "socketPathRequired": "소켓 경로가 필요합니다", + "serverRequired": "서버가 필요합니다", + "portRangeError": "0과 65535 사이의 포트를 입력", + "portInUseValue": "포트 {port}은 이미 사용 중입니다", + "backgroundLocationPermissionRequired": "항상 위치 액세스는 배경 다운로드에 필요한", + "scriptPath": "스크립트 경로", + "logDirectoryUnavailable": "로그인 디렉토리는 초기화되지 않습니다", + "unableOpenExternalLink": "외부 링크 열기", + "neverUpdated": "업데이트", + "lastUpdated": "마지막 업데이트: {time}", + "unableLoadSettings": "설정을 불러올 수 없음", + "fieldRequired": "{field}은 필수입니다", + "addGithubMirror": "GitHub 미러 추가", + "editGithubMirror": "GitHub 미러", + "confirmDeleteTitle": "삭제를 확인?", + "confirmDeleteNamed": "당신은 삭제를 원하십니까 “{name}”?", + "categoryFieldsRequired": "범주 이름과 경로가 필요합니다", + "addCategory": "카테고리 추가", + "editCategory": "관련 기사", + "deleteCategoryTitle": "범주 삭제?", + "updateFailed": "업데이트 실패. 나중에 다시 시도하십시오", + "newVersionAvailable": "새 버전은 사용할 수 있습니다", + "releaseNotes": "새로운 소식", + "noReleaseNotes": "출시 노트", + "preparingDownload": "다운로드 준비 ...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed 백그라운드 서비스", + "androidForegroundNotificationTitle": "Gopeed가 실행 중입니다", + "androidForegroundNotificationText": "백그라운드에서 다운로드를 계속할 수 있습니다" +} diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb new file mode 100644 index 00000000..cddb61c2 --- /dev/null +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "pl", + "label": "Polski", + "error": "Błąd", + "tip": "Wskazówka", + "confirm": "Potwierdź", + "confirmDelete": "Czy na pewno chcesz usunąć?", + "cancel": "Anuluj", + "on": "Włącz", + "off": "Wyłącz", + "selectAll": "Zaznacz wszystko", + "select": "Wybierz", + "task": "Zadania", + "downloading": "Pobierane", + "downloaded": "Pobrane", + "setting": "Ustawienia", + "donate": "Wesprzyj", + "exit": "Wyjście", + "pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść", + "create": "Utwórz zadanie", + "directDownload": "Pobieranie bezpośrednie", + "advancedOptions": "Zaawansowane", + "followSettings": "Podążaj za ustawieniami", + "downloadLink": "Pobierz z linku", + "downloadLinkValid": "Otwórz link pobierania", + "downloadLinkHit": "Otwórz link pobierania, HTTP/HTTPS/MAGNET wspierane@append", + "downloadLinkHitDesktop": ", lub przenieś bezpośrednio plik torrent", + "download": "Pobierz", + "noFileSelected": "Wybierz co najmniej jeden plik aby kontynuować", + "noStoragePermission": "Przyznaj uprawnienia do zapisu", + "selectFile": "Wybierz plik", + "rename": "Zmień nazwę", + "basic": "Zwykły", + "advanced": "Zaawansowane", + "general": "Ogólne", + "downloadDir": "Folder Pobierania", + "rememberLastDownloadDir": "Automatycznie zapisuj ostatni katalog zadania", + "downloadDirValid": "Wskaż folder pobierania", + "connections": "Połączenia", + "useServerCtime": "Użyj czasu serwera do utworzenia pliku", + "maxRunning": "Maksymalna liczba zadań", + "defaultDirectDownload": "Domyślnie sprawdź bezpośredni pobór", + "autoStartTasks": "Automatycznie uruchamiaj niekompletne zadania przy starcie", + "autoTorrentEnable": "Automatyczne tworzenie zadań BT z plików .torrent", + "autoTorrentDeleteAfterDownload": "Usuń plik .torrent po utworzeniu zadania BT", + "autoDeleteMissingFileTasks": "Automatycznie usuwaj zadania z brakującymi plikami", + "items": "{count} elementów", + "subscribeTracker": "Subskrybuj tracker", + "subscribeFail": "Błąd subskrypcji, sprawdź łączność z internetem", + "update": "Aktualizacja", + "updateDaily": "Aktualizuj codziennie", + "lastUpdate": "Ostatnia aktualizacja: @time", + "addTracker": "Dodaj tracker", + "addTrackerHit": "Wpisz adres trackera w jednej linii", + "ui": "Wygląd", + "theme": "Schemat kolorów", + "themeSystem": "Systemowy", + "themeLight": "Jasny", + "themeDark": "Ciemny", + "locale": "Język", + "notifyWhenNewVersion": "Powiadomienie o aktualizacjach", + "analyticsEnabled": "Wysyłanie statystyk", + "analyticsEnabledDesc": "Udostępnianie anonimowych danych o używaniu, które pomogą nam poprawić", + "about": "Info", + "homepage": "Strona WWW", + "version": "Wersja", + "protocol": "Protokół", + "port": "Port", + "apiToken": "API Token", + "notSet": "Nie ustawiono", + "set": "Ustawiono", + "portInUse": "Port [@port] w użyciu, proszę zmień port", + "effectAfterRestart": "Zmiany po restarcie", + "developer": "Programista", + "logDirectory": "Katalog dziennika", + "webhook": "Hak internetowy", + "webhookEnable": "Włącz haczyk", + "webhookDesc": "Wyślij powiadomienia HTTP POST, gdy zadania się zakończą lub nie", + "webhookUrlHint": "Wprowadź adres URL haka", + "webhookTest": "Badanie", + "webhookTestSuccess": "Test na haku internetowym zakończony sukcesem", + "webhookTestFail": "Test na haku nie powiódł się", + "script": "Skrypt", + "scriptEnable": "Włącz skrypt", + "scriptDesc": "Wykonaj własne skrypty, gdy pobieranie zakończy się pomyślnie", + "scriptPathHint": "Wprowadź ścieżkę pliku skryptu (np. / path / to / script.sh)", + "urlInvalid": "Proszę podać poprawny adres HTTP lub HTTPS", + "required": "To pole jest wymagane", + "show": "Pokaż", + "continueAction": "Kontynuuj", + "pause": "Pauza", + "startAll": "Zacznij wszystkie", + "pauseAll": "Zatrzymaj wszystkie", + "deleteTask": "Usuń @count zadania", + "deleteTaskTip": "Zachowaj pobrane pliki", + "delete": "Usuń", + "add": "Dodaj", + "edit": "Edycja", + "newVersionTitle": "Sprawdź aktualizację @version", + "newVersionUpdate": "Aktualizuj teraz", + "newVersionLater": "Później", + "extensions": "Wtyczki", + "extensionInstallUrl": "Instaluj z url", + "extensionInstallSuccess": "Instalacja zakończona", + "extensionUpdateSuccess": "Aktualizacja zakończona", + "extensionDelete": "Usuń wtyczkę", + "extensionAlreadyLatest": "Wtyczka w aktualnej wersji", + "extensionFind": "Wyszukaj wtyczkę", + "extensionDevelop": "Stwórz wtyczkę", + "extensionStoreTitle": "Sklep rozszerzeń", + "extensionStoreSection": "Przedłużenia przechowywania", + "extensionInstalledSection": "Zainstalowane rozszerzenia", + "extensionInstalledEmpty": "Nie zainstalowano jeszcze rozszerzeń", + "extensionStoreEmpty": "Nie znaleziono dopasowania rozszerzeń", + "extensionSearchHint": "Szukaj po imieniu, tytule, opisie lub autorze", + "extensionSortStars": "Większość gwiazd", + "extensionSortInstalls": "Większość instalacji", + "extensionSortUpdated": "Ostatnio zaktualizowane", + "extensionSortToggle": "Włączenie / wyłączenie kierunku sortowania", + "extensionLoadMore": "Wczytaj więcej", + "extensionNoMore": "Koniec z przedłużeniami", + "extensionInstalled": "Instalowane", + "extensionCanUpdate": "Aktualizacja dostępna", + "extensionInstall": "Instalacja", + "extensionLoadLocal": "Zainstaluj lokalny folder", + "extensionFilterAll": "Wszystkie", + "extensionFilterMarket": "Rynek", + "extensionFilterInstalled": "Instalowane", + "extensionManualInstall": "Instalacja ręczna", + "extensionInstallTools": "Zainstaluj opcje", + "history": "Historia", + "clearHistory": "Wyczyść historię", + "noHistoryFound": "Brak historii", + "serviceTitle": "Usługa pobierania", + "serviceText": "Trwa", + "network": "Sieć", + "proxy": "Proxy", + "noProxy": "Bez Proxy", + "systemProxy": "Systemowe Proxy", + "customProxy": "Własne Proxy", + "server": "Serwer", + "username": "Użytkownik", + "password": "Hasło", + "thanks": "Dzięki", + "thanksDesc": "Dziękuję wszystkim za pomoc przy powstawaniu aplikacji Gopeed!", + "browserExtension": "Wtyczka dla przeglądarki", + "launchAtStartup": "Uruchom na starcie", + "runAsMenubarApp": "Uruchom jako aplikacja paska menu", + "runAsMenubarAppDesc": "Ukryj ikonę Docka i uruchom tylko w pasku menu", + "seedConfig": "Konfig nasion", + "seedKeep": "Siew siewny do momentu ręcznego zatrzymania", + "seedRatio": "Stosunek materiału siewnego", + "seedTime": "Czas nasadzenia (minuty)", + "setAsDefaultBtClient": "Ustaw jako domyślny klient BT", + "taskDetail": "Szczegóły zadania", + "taskName": "Nazwa zadania", + "taskUrl": "URL zadania", + "downloadPath": "Ścieżka do pobrania", + "skipVerifyCert": "Pomiń weryfikację certyfikatu", + "archives": "Archiwa", + "autoExtract": "Automatycznie wyodrębnij archiwa", + "archivePassword": "Hasło archiwum", + "archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła", + "deleteAfterExtract": "Usuń archiwum po rozpakowaniu", + "extracting": "Rozpakowywanie", + "extractDone": "Rozpakowywanie zakończone", + "extractError": "Rozpakowywanie nie powiodło się", + "waitingParts": "Oczekiwanie na części", + "name": "Nazwa", + "size": "Rozmiar", + "unknown": "Nieznany", + "fileSelectedCount": "Pliki:", + "fileSelectedSize": "Rozmiar:", + "httpHeader": "Nagłówek", + "httpHeaderName": "Nazwa nagłówka", + "httpHeaderValue": "Wartość nagłówka", + "login": "Zaloguj", + "username_required": "Proszę podać nazwę użytkownika", + "password_required": "Proszę podać hasło", + "login_success": "Zaloguj się z powodzeniem", + "login_failed": "Zaloguj się niepowodzeniem, sprawdź nazwę użytkownika i hasło", + "login_failed_network": "Zaloguj się niepowodzeniem, sprawdź połączenie sieciowe", + "insertPlaceholder": "Wstaw placeholder", + "placeholderYear": "Bieżący rok", + "placeholderMonth": "Bieżący miesiąc (01-12)", + "placeholderDay": "Bieżący dzień (01-31)", + "placeholderDate": "Pełna data (RRRR-MM-DD)", + "example": "np. @value", + "downloadCategories": "Kategorie pobierania", + "categoryMusic": "Muzyka", + "categoryVideo": "Wideo", + "categoryDocument": "Dokument", + "categoryProgram": "Programy", + "categoryName": "Nazwa kategorii", + "categoryPath": "Ścieżka kategorii", + "builtInCategory": "Wbudowanej kategorii nie można usunąć", + "selectCategory": "Wybierz kategorię", + "quickDirectories": "Szybkie foldery", + "githubMirror": "Lustro GitHub", + "githubMirrorEnable": "Włącz lusterko GitHub", + "githubMirrorDesc": "Użyj luster, aby przyspieszyć pobieranie zawartości GitHub", + "githubMirrorType": "Typ lustra", + "githubMirrorUrl": "Lusterko URL", + "githubMirrorUrlHint": "Wpisz adres URL lustrzanego", + "updateUrl": "Aktualizuj URL", + "updateUrlManual": "Ręczna aktualizacja", + "updateUrlListen": "Uaktualnienie", + "updateUrlListeningTip": "Oczekiwanie na aktualizację URL", + "updateUrlCancelListen": "Anuluj słuchanie", + "updateUrlDialogHint": "Wprowadź nowy adres URL pobierania", + "pendingUpdateFound": "Odnalezione zadanie do czasu aktualizacji", + "pendingUpdateConfirm": "Zadanie \"{name}\" czeka na aktualizację URL. Czy chcesz go zaktualizować nowym URL?", + "pendingUpdateYes": "Uaktualnij zadanie", + "pendingUpdateNo": "Utwórz nowe zadanie", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP Słuchaj portu", + "ed2kUdpPort": "Port UDP Listen", + "ed2kServerList": "Adresy serwera", + "ed2kServerMet": "Server.met Źródła", + "ed2kNodesDat": "nodes.dat Źródła", + "ed2kAutoPort": "Automatyczne przypisywanie", + "ed2kOnePerLine": "Jeden wpis na linię", + "ed2kServersHint": "host: port, jeden na linię", + "ed2kServerMetHint": "Server.met URL lub ed2k link do listy serwerowej, jeden na linię", + "ed2kNodesDatHint": "ścieżka nodes.dat lub URL, jeden na linię", + "desktopNotification": "Powiadomienia na pulpicie", + "backgroundLocationKeepAlive": "Pobieranie w tle", + "notificationTaskDone": "Zadanie zakończone", + "notificationTaskError": "Błąd zadania", + "appTitle": "Menedżer pobierania Gopeed", + "unsupportedWindowPayload": "Nieobsługiwane żądanie okna", + "runtimeInitializationFailed": "Nie można zainicjować środowiska wykonawczego Gopeed.\n{error}", + "save": "Zapisz", + "retry": "Ponów", + "close": "Zamknij", + "back": "Wstecz", + "open": "Otwórz", + "openFile": "Otwórz plik", + "share": "Udostępnij", + "copy": "Kopiuj", + "copied": "Kopiowane", + "refresh": "Odśwież", + "status": "Stan", + "createdAt": "Czas utworzenia", + "speed": "Prędkość", + "uploadSpeed": "Prędkość wysyłania", + "remaining": "Pozostały", + "downloadDuration": "Czas pobierania", + "progress": "Postępy", + "failed": "Nieudany", + "completed": "Zakończono", + "waiting": "Czekam", + "taskFailed": "Zadanie nie powiodło się", + "unknownSize": "Nieznany rozmiar", + "secondsRemaining": "Pozostały {count} sekundy", + "minutesRemaining": "Pozostały {count} minuty", + "hoursRemaining": "Pozostały {count} godziny", + "secondsDuration": "{count} sekund", + "minutesDuration": "{count} minut", + "hoursDuration": "{count} godzin", + "followSystem": "Podążaj za systemem", + "basicSettings": "Ustawienia podstawowe", + "downloadSettings": "Ustawienia pobierania", + "advancedSettings": "Ustawienia zaawansowane", + "themeColor": "Kolor motywu", + "themeColorDescription": "Dostosuj kolor akcentu aplikacji", + "browserExtensionDescription": "Wyślij pliki do pobrania przeglądarki bezpośrednio do Gopeed po instalacji", + "desktopNotificationDescription": "Pokaż powiadomienie systemowe, gdy zadanie zakończy się lub nie", + "backgroundLocationKeepAliveDescription": "Użyj dostępu do lokalizacji, aby utrzymać pliki do pobrania aktywne w tle", + "currentVersion": "Aktualna wersja {version}", + "contributors": "Uczestnicy", + "viewContributors": "Wyświetlanie podmiotów przekazujących dane", + "maxRunningDescription": "Maksymalna liczba zadań, które mogą być jednocześnie pobierane", + "defaultUserAgentDescription": "Domyślny User- Agent do pobierania HTTP i HTTPS", + "listenPort": "Port nasłuchiwania", + "onePerLine": "Jeden wpis na linię", + "proxyProtocol": "Protokół w sprawie proxy", + "restartRequired": "To ustawienie działa po ponownym uruchomieniu aplikacji", + "socketPath": "Ścieżka gniazda", + "listenAddress": "Adres", + "currentConnection": "Bieżące połączenie", + "webhookPush": "Punkty końcowe dla haka internetowego", + "webhookPushDescription": "Wyślij powiadomienia HTTP POST do tych punktów końcowych, gdy zadania zakończone lub nieudane", + "scriptExecution": "Skrypty", + "scriptExecutionDescription": "Uruchom te skrypty po pomyślnym zakończeniu pobierania", + "addWebhook": "Dodaj haczyk", + "editWebhook": "Edytuj haczyk", + "addScript": "Dodaj skrypt", + "editScript": "Edytuj skrypt", + "openDirectory": "Otwórz katalog", + "browseFiles": "Przeglądaj pliki", + "updateEveryDay": "Aktualizacja codziennie", + "updateVersion": "Aktualizacja", + "checkForUpdates": "Sprawdź aktualizacje", + "updating": "Aktualizacja...", + "colorGreen": "Zielony", + "colorRed": "Czerwony", + "colorRose": "Róża", + "colorOrange": "Pomarańczowy", + "colorCyan": "Cyjan", + "colorBlue": "Niebieski", + "colorAmber": "Bursztynowy", + "colorPurple": "Fioletowy", + "noExtensions": "Brak rozszerzeń", + "searchExtensions": "Wyszukaj rozszerzenia...", + "extensionDeveloperGuide": "Rozszerzenie prowadnicy deweloperskiej", + "extensionInstallFromUrl": "Zainstaluj rozszerzenie z URL", + "unableOpenExtensionDocs": "Nie można otworzyć przewodnika dla deweloperów", + "removeExtensionTitle": "Odinstaluj rozszerzenie?", + "removeExtensionConfirm": "Jesteś pewien, że chcesz odblokować \"{name}\"?", + "uninstall": "Odinstaluj", + "unableLoadExtensions": "Nie można wczytać rozszerzeń", + "filters": "Filtry", + "allTasks": "Wszystkie zadania", + "searchDownloads": "Wyszukaj pliki do pobrania...", + "searchHistory": "Przeszukaj historię…", + "addTask": "Dodaj zadanie", + "emptyTaskList": "Brak zadań na tej liście", + "noMatchingTasks": "Brak dopasowania zadań", + "taskList": "Lista zadań", + "selectedCount": "Wybrane {selected} / {total}", + "unableOpenPath": "Nie można otworzyć {path}", + "unableLocatePath": "Nie można zlokalizować {path}", + "clearSelection": "Wyczyść zaznaczenie", + "deselectTask": "Odznacz", + "selectTask": "Wybierz", + "updateManually": "Aktualizacja ręcznie", + "stopListening": "Przestań słuchać", + "listenForUrl": "Wyszukaj adres URL", + "waitingForReplacementUrl": "Oczekiwanie na zastępczy adres URL", + "taskNotFound": "Zadanie nie znaleziono", + "storagePath": "Ścieżka do przechowywania", + "directory": "Katalog", + "mode": "Tryb", + "keepOriginalName": "Zachowaj oryginalną nazwę", + "enterCount": "Wprowadź liczbę", + "chooseDownloadDirectory": "Wybierz katalog pobierania", + "applicationStorage": "Pamięć aplikacji", + "applicationStorageDescription": "Niezawodna pamięć aplikacji; pliki są usuwane po odinstalowaniu aplikacji", + "publicDownloadsDescription": "Zachowuje pliki po odinstalowaniu; dostępność zależy od urządzenia z Androidem", + "downloadsDirectoryUnavailable": "Gopeed nie może zapisywać w Download/Gopeed na tym urządzeniu", + "enterValue": "Wprowadź wartość", + "trackers": "Trackers", + "oneTrackerPerLine": "Jeden adres URL trackera na linię", + "pasteDownloadLinks": "Wklej HTTP, HTTPS lub MAGNET linków, lub upuść plik torrent tutaj", + "unableReadTorrent": "Nie można odczytać pliku torrent: {error}", + "torrentOnly": "Obsługiwane są tylko pliki .torrent", + "torrentDataEmpty": "Dane pliku torrenta są puste", + "torrentPathUnavailable": "Nie można uzyskać dostępu do ścieżki pliku torrenta", + "selectAtLeastOneFile": "Wybierz co najmniej jeden plik", + "resolvedFiles": "Rozwiązywane pliki", + "resolvedFileCount": "{count} plików rozwiązanych", + "resourceHasNoFiles": "Zasób nie ma listy plików do wyboru", + "noFiles": "Brak plików", + "createHistory": "Utwórz historię", + "noHistory": "Brak historii", + "directDownloadDescription": "Pomiń rozwiązywanie i tworzyć zadania natychmiast", + "taskProgress": "Postępy w realizacji zadań", + "updateAndResume": "Aktualizacja i wznowienie", + "httpHeaders": "Nagłówki HTTP", + "piece": "Kawałek {number}", + "pieceRange": "Kawałki {start}- {end}", + "connectionStatus": "Stan połączenia", + "connectionCount": "{count} połączeń", + "retrying": "Przekraczanie", + "connectionFailed": "Połączenie nie powiodło się", + "connectionFailedRetries": "Połączenie nie powiodło się po {count} próbach", + "retryingCount": "Ponawianie · próba {count}", + "pieceStatus": "Status kawałka", + "peers": "Partnerzy", + "seeders": "Siewniki", + "leechers": "Leechery", + "downloadedAmount": "Ściągnięty", + "uploadedAmount": "Wysłany", + "shareRatio": "Współczynnik udostępniania", + "shareDuration": "Czas udostępniania", + "connectedPeers": "{count} podłączony", + "active": "Aktywne", + "completedPieces": "Ukończone części", + "activePeers": "{count} aktywnych", + "incomplete": "Niekompletne", + "address": "Adres", + "client": "Klient", + "pieces": "Kawałki", + "source": "Źródło", + "fileRelevance": "Istotność plików", + "taskDetails": "Szczegóły zadania", + "taskDetailsInfoTab": "Szczegóły", + "taskDetailsFilesTab": "Pliki", + "taskDetailsStatsTab": "Statystyki", + "noStatistics": "Brak danych statystycznych", + "noPeers": "Brak połączeń", + "deleteTasksTitle": "Usuwa {count} zadania?", + "keepFilesDescription": "Usuń tylko wpis zadania z Gopeed", + "readingRuntimeStatus": "Odczyt statusu runtime...", + "apiConfigSaved": "Zapisane ustawienia API. Przywróć aplikację, aby je zastosować", + "socketPathRequired": "Ścieżka gniazda jest wymagana", + "serverRequired": "Wymagany jest serwer", + "portRangeError": "Wprowadź port pomiędzy 0 a 65535", + "portInUseValue": "Port {port} jest już w użyciu", + "backgroundLocationPermissionRequired": "Zawsze zezwalaj na dostęp do lokalizacji jest wymagany dla pobierania tła", + "scriptPath": "Ścieżka skryptu", + "logDirectoryUnavailable": "Katalog dziennika nie jest inicjalizowany", + "unableOpenExternalLink": "Nie można otworzyć połączenia zewnętrznego", + "neverUpdated": "Nigdy nie aktualizowano", + "lastUpdated": "Ostatnia aktualizacja: {time}", + "unableLoadSettings": "Nie można wczytać ustawień", + "fieldRequired": "{field} jest wymagane", + "addGithubMirror": "Dodaj lustro GitHub", + "editGithubMirror": "Edytuj lusterko GitHub", + "confirmDeleteTitle": "Potwierdzić usunięcie?", + "confirmDeleteNamed": "Na pewno chcesz usunąć \"{name}\"?", + "categoryFieldsRequired": "Wymagane jest podanie nazwy kategorii i ścieżki", + "addCategory": "Dodaj kategorię", + "editCategory": "Edytuj kategorię", + "deleteCategoryTitle": "Skasować kategorię?", + "updateFailed": "Uaktualnienie nie powiodło się. Proszę spróbować ponownie później", + "newVersionAvailable": "Nowa wersja jest dostępna", + "releaseNotes": "Co nowego?", + "noReleaseNotes": "Brak informacji o wydaniu", + "preparingDownload": "Przygotowanie pobierania...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Usługa Gopeed w tle", + "androidForegroundNotificationTitle": "Gopeed jest uruchomiony", + "androidForegroundNotificationText": "Pobieranie może być kontynuowane w tle" +} diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb new file mode 100644 index 00000000..b920c038 --- /dev/null +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "pt", + "label": "Português (Brasil)", + "error": "Erro", + "tip": "Dica", + "confirm": "Confirmar", + "confirmDelete": "Tem certeza de que deseja excluir?", + "cancel": "Cancelar", + "on": "Ligado", + "off": "Desligado", + "selectAll": "Selecionar Tudo", + "select": "Selecionar", + "task": "Tarefas", + "downloading": "baixando", + "downloaded": "baixado", + "setting": "Configurações", + "donate": "Doação", + "exit": "Sair", + "pressBackAgainToExit": "Prima novamente Voltar para sair", + "create": "Criar Tarefa", + "directDownload": "Download Direto", + "advancedOptions": "Opções Avançadas", + "followSettings": "Seguir as Configurações", + "downloadLink": "Link de Download", + "downloadLinkValid": "Por favor, insira o link de download", + "downloadLinkHit": "Por favor, insira o link de download, um por linha@append", + "downloadLinkHitDesktop": ", ou arraste o arquivo torrent aqui", + "download": "Baixar", + "noFileSelected": "Por favor, selecione ao menos um arquivo para continuar", + "noStoragePermission": "Permissão de Armazenamento Necessária", + "selectFile": "Selecionar Arquivo", + "rename": "Renomear para", + "basic": "Configurações Básica", + "advanced": "Configurações Avançada", + "general": "Principal", + "downloadDir": "Onde você deseja salvar?", + "rememberLastDownloadDir": "Salvar automaticamente o último diretório da tarefa", + "downloadDirValid": "Por favor, selecione a pasta", + "connections": "Conexões", + "useServerCtime": "Usar o tempo de servidor para a criação de ficheiros", + "maxRunning": "Máximo de Tarefas Simultâneas", + "defaultDirectDownload": "Marcar download direto por padrão", + "autoStartTasks": "Iniciar automaticamente tarefas incompletas ao iniciar", + "autoTorrentEnable": "Criar automaticamente tarefas BT de arquivos .torrent", + "autoTorrentDeleteAfterDownload": "Excluir arquivo .torrent após criação da tarefa BT", + "autoDeleteMissingFileTasks": "Excluir automaticamente tarefas com arquivos ausentes", + "items": "{count} itens", + "subscribeTracker": "Assinar Rastreador", + "subscribeFail": "Falha na Assinatura, verifique sua conexão ou tente novamente mais tarde", + "update": "Atualizar", + "updateDaily": "Atualizar Diariamente", + "lastUpdate": "Última Atualização: @time", + "addTracker": "Adicionar Rastreador", + "addTrackerHit": "Por favor, insira a URL do rastreador, um por linha", + "ui": "Interface", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "locale": "Idioma", + "notifyWhenNewVersion": "Notificar as atualizações", + "analyticsEnabled": "Enviar estatísticas", + "analyticsEnabledDesc": "Compartilhe dados anônimos de uso para nos ajudar a melhorar", + "about": "Sobre", + "homepage": "Site", + "version": "Versão", + "protocol": "Protocolo", + "port": "Porta", + "apiToken": "Token de API", + "notSet": "Não definido", + "set": "Definido", + "portInUse": "Porta [@port] em uso, por favor utilize outra porta", + "effectAfterRestart": "Efeito após reiniciar", + "developer": "Desenvolvedor", + "logDirectory": "Pasta de Logs", + "webhook": "Webhook", + "webhookEnable": "Activar o Webhook", + "webhookDesc": "Enviar notificações HTTP POST quando as tarefas completam ou falham", + "webhookUrlHint": "Digite o URL do webhook", + "webhookTest": "Ensaio", + "webhookTestSuccess": "Teste Webhook com sucesso", + "webhookTestFail": "Falha no teste Webhook", + "script": "Programa", + "scriptEnable": "Activar o Programa", + "scriptDesc": "Executar scripts personalizados quando os downloads forem concluídos com sucesso", + "scriptPathHint": "Indique o caminho do ficheiro de script (por exemplo, /path/to/script.sh)", + "urlInvalid": "Digite um URL HTTP ou HTTPS válido", + "required": "Este campo é necessário", + "show": "Mostrar", + "continueAction": "Continuar", + "pause": "Pausar", + "startAll": "Iniciar Tudo", + "pauseAll": "Pausar Tudo", + "deleteTask": "Excluir @count tarefas", + "deleteTaskTip": "Manter arquivos baixados", + "delete": "Excluir", + "add": "Adicionar", + "edit": "Editar", + "newVersionTitle": "Nova Versão Disponível @version", + "newVersionUpdate": "Atualizar Agora", + "newVersionLater": "Mais Tarde", + "extensions": "Extensões", + "extensionInstallUrl": "Link de Instalação", + "extensionInstallSuccess": "Instalado com Sucesso", + "extensionUpdateSuccess": "Atualizado com Sucesso", + "extensionDelete": "Excluir Extensão", + "extensionAlreadyLatest": "Já é a última versão", + "extensionFind": "Encontrar Extensões", + "extensionDevelop": "Desenvolvedor", + "extensionStoreTitle": "Armazenagem de Extensão", + "extensionStoreSection": "Armazenar extensões", + "extensionInstalledSection": "Extensões Instaladas", + "extensionInstalledEmpty": "Nenhuma extensão ainda instalada", + "extensionStoreEmpty": "Nenhuma extensão correspondente encontrada", + "extensionSearchHint": "Procurar pelo nome, título, descrição ou autor", + "extensionSortStars": "A maioria das estrelas", + "extensionSortInstalls": "A maioria das Instalações", + "extensionSortUpdated": "Actualizado Recentemente", + "extensionSortToggle": "Alternar a direção de ordenação", + "extensionLoadMore": "Carregar mais", + "extensionNoMore": "Sem mais extensões", + "extensionInstalled": "Instalado", + "extensionCanUpdate": "Actualização Disponível", + "extensionInstall": "Instalar", + "extensionLoadLocal": "Instalar pasta local", + "extensionFilterAll": "Tudo", + "extensionFilterMarket": "Mercado", + "extensionFilterInstalled": "Instalado", + "extensionManualInstall": "Instalação manual", + "extensionInstallTools": "Opções de instalação", + "history": "Histórico", + "clearHistory": "Limpar Histórico", + "noHistoryFound": "Nenhum Histório Encontrado", + "serviceTitle": "Serviço de Download", + "serviceText": "Executando", + "network": "Rede", + "proxy": "Proxy", + "noProxy": "Sem Proxy", + "systemProxy": "Proxy do Sistema", + "customProxy": "Proxy Personalizado", + "server": "Servidor", + "username": "Usuário", + "password": "Senha", + "thanks": "Obrigado", + "thanksDesc": "Obrigado a todos os colaboradores que ajudaram a construir e desenvolver a comunidade Gopeed!", + "browserExtension": "Extensões para Navegadores", + "launchAtStartup": "Lançar no arranque", + "runAsMenubarApp": "Executar como aplicativo da barra de menus", + "runAsMenubarAppDesc": "Ocultar ícone do Dock e executar apenas na barra de menus", + "seedConfig": "Configuração da Semente", + "seedKeep": "Manter a semeadura até parar manualmente", + "seedRatio": "Relação entre sementes", + "seedTime": "Tempo de semente (minutos)", + "setAsDefaultBtClient": "Definir como o cliente BT padrão", + "taskDetail": "Detalhes da Tarefa", + "taskName": "Nome da Tarefa", + "taskUrl": "Tarefa URL", + "downloadPath": "Localização de Transferência", + "skipVerifyCert": "Pular Verificação de Certificado", + "archives": "Arquivos", + "autoExtract": "Extrair arquivos automaticamente", + "archivePassword": "Senha do arquivo", + "archivePasswordHint": "Deixar vazio se não estiver protegido por senha", + "deleteAfterExtract": "Excluir arquivo após extração", + "extracting": "Extraindo", + "extractDone": "Extração concluída", + "extractError": "Falha na extração", + "waitingParts": "Aguardando partes", + "name": "Nome", + "size": "Tamanho", + "unknown": "Desconhecido", + "fileSelectedCount": "Arquivos: ", + "fileSelectedSize": "Tamanho: ", + "httpHeader": "Cabeçalho", + "httpHeaderName": "Nome do Header", + "httpHeaderValue": "Valor do Header", + "login": "Login", + "username_required": "Digite seu nome de usuário", + "password_required": "Digite sua senha", + "login_success": "Login com sucesso", + "login_failed": "Falha na autenticação, verifique por favor o seu nome de utilizador e senha", + "login_failed_network": "Falha na autenticação, verifique por favor a sua ligação à rede", + "insertPlaceholder": "Inserir marcador", + "placeholderYear": "Ano atual", + "placeholderMonth": "Mês atual (01-12)", + "placeholderDay": "Dia atual (01-31)", + "placeholderDate": "Data completa (AAAA-MM-DD)", + "example": "ex. @value", + "downloadCategories": "Categorias de Download", + "categoryMusic": "Música", + "categoryVideo": "Vídeo", + "categoryDocument": "Documento", + "categoryProgram": "Programa", + "categoryName": "Nome da Categoria", + "categoryPath": "Caminho da Categoria", + "builtInCategory": "Categoria integrada não pode ser excluída", + "selectCategory": "Selecionar Categoria", + "quickDirectories": "Pastas rápidas", + "githubMirror": "Espelho GitHub", + "githubMirrorEnable": "Activar o Espelho GitHub", + "githubMirrorDesc": "Usar espelhos para acelerar downloads de conteúdo GitHub", + "githubMirrorType": "Tipo de Espelho", + "githubMirrorUrl": "URL do espelho", + "githubMirrorUrlHint": "Digite o URL do espelho", + "updateUrl": "Atualizar URL", + "updateUrlManual": "Actualização Manual", + "updateUrlListen": "Ouvir Actualização", + "updateUrlListeningTip": "Aguardando atualização do URL", + "updateUrlCancelListen": "Cancelar Ouvir", + "updateUrlDialogHint": "Digite o novo URL de download", + "pendingUpdateFound": "Tarefa de Atualização Pendente Encontrada", + "pendingUpdateConfirm": "A tarefa \"{name}\" está à espera da actualização do URL. Deseja atualizá- la com o novo URL?", + "pendingUpdateYes": "Actualizar a Tarefa", + "pendingUpdateNo": "Criar uma Nova Tarefa", + "ed2k": "ED2K", + "ed2kTcpPort": "Porta de escuta TCP", + "ed2kUdpPort": "Porta de escuta UDP", + "ed2kServerList": "Endereços do Servidor", + "ed2kServerMet": "Fontes Server.met", + "ed2kNodesDat": "fontes de dados de nós", + "ed2kAutoPort": "Atribuir automaticamente", + "ed2kOnePerLine": "Uma entrada por linha", + "ed2kServersHint": "máquina:port, um por linha", + "ed2kServerMetHint": "URL Server.met ou ed2k Serverlist link, um por linha", + "ed2kNodesDatHint": "nodes.dat caminho ou URL, um por linha", + "desktopNotification": "Notificações na área de trabalho", + "backgroundLocationKeepAlive": "Fundo Download Manter vivo", + "notificationTaskDone": "Tarefa concluída", + "notificationTaskError": "Erro na tarefa", + "appTitle": "Gerenciador de downloads Gopeed", + "unsupportedWindowPayload": "Pedido de janela não suportado", + "runtimeInitializationFailed": "Não foi possível inicializar o tempo de execução do Gopeed. {error}", + "save": "Gravar", + "retry": "Repetir", + "close": "Fechar", + "back": "Voltar", + "open": "Abrir", + "openFile": "Abrir ficheiro", + "share": "Partilhar", + "copy": "Copiar", + "copied": "Copiado", + "refresh": "Actualizar", + "status": "Estado", + "createdAt": "Data de criação", + "speed": "Velocidade", + "uploadSpeed": "Velocidade de envio", + "remaining": "Restante", + "downloadDuration": "Duração da transferência", + "progress": "Progresso", + "failed": "Falha", + "completed": "Concluído", + "waiting": "Esperando", + "taskFailed": "Tarefa falhou", + "unknownSize": "Tamanho desconhecido", + "secondsRemaining": "Restam {count} segundos", + "minutesRemaining": "{count} minutos restantes", + "hoursRemaining": "{count} horas restantes", + "secondsDuration": "{count} segundos", + "minutesDuration": "{count} minutos", + "hoursDuration": "{count} horas", + "followSystem": "Seguir o sistema", + "basicSettings": "Configuração Básica", + "downloadSettings": "Configuração da Transferência", + "advancedSettings": "Configuração Avançada", + "themeColor": "Cor do Tema", + "themeColorDescription": "Personalizar a cor do sotaque da aplicação", + "browserExtensionDescription": "Enviar downloads do navegador diretamente para o Gopeed após a instalação", + "desktopNotificationDescription": "Mostrar uma notificação do sistema quando uma tarefa terminar ou falhar", + "backgroundLocationKeepAliveDescription": "Usar o acesso de localização para manter os downloads ativos em segundo plano", + "currentVersion": "Versão atual {version}", + "contributors": "Contribuidores", + "viewContributors": "Ver os contribuidores", + "maxRunningDescription": "Número máximo de tarefas que podem ser transferidas ao mesmo tempo", + "defaultUserAgentDescription": "Agente padrão do usuário para downloads HTTP e HTTPS", + "listenPort": "Ouvir Porto", + "onePerLine": "Uma entrada por linha", + "proxyProtocol": "Protocolo de Proxy", + "restartRequired": "Esta configuração produz efeito após reiniciar o aplicativo", + "socketPath": "Localização do 'socket'", + "listenAddress": "Ouvir endereço", + "currentConnection": "Conexão atual", + "webhookPush": "Pontos de Finalização do Webhook", + "webhookPushDescription": "Enviar notificações HTTP POST para estes pontos de avaliação quando as tarefas completam ou falham", + "scriptExecution": "Programas", + "scriptExecutionDescription": "Executar estes scripts após um download terminar com sucesso", + "addWebhook": "Adicionar o Webhook", + "editWebhook": "Editar o Webhook", + "addScript": "Adicionar um Programa", + "editScript": "Editar o Programa", + "openDirectory": "Abrir a Pasta", + "browseFiles": "Procurar arquivos", + "updateEveryDay": "Actualizar diariamente", + "updateVersion": "Actualizar", + "checkForUpdates": "Verificar as Atualizações", + "updating": "Actualizando...", + "colorGreen": "Verde", + "colorRed": "Vermelho", + "colorRose": "Rosa", + "colorOrange": "Laranja", + "colorCyan": "Ciano", + "colorBlue": "Azul", + "colorAmber": "Amber", + "colorPurple": "Roxo", + "noExtensions": "Sem extensões", + "searchExtensions": "Procurar extensões...", + "extensionDeveloperGuide": "Guia do desenvolvedor de extensão", + "extensionInstallFromUrl": "Instalar extensão a partir de URL", + "unableOpenExtensionDocs": "Não foi possível abrir o guia do desenvolvedor da extensão", + "removeExtensionTitle": "Desinstalar extensão?", + "removeExtensionConfirm": "Tem certeza de que deseja desinstalar “{name}”?", + "uninstall": "Desinstalar", + "unableLoadExtensions": "Não foi possível carregar extensões", + "filters": "Filtros", + "allTasks": "Todas as Tarefas", + "searchDownloads": "Procurar downloads...", + "searchHistory": "Pesquisar histórico…", + "addTask": "Adicionar Tarefa", + "emptyTaskList": "Nenhuma tarefa nesta lista", + "noMatchingTasks": "Não foram encontradas tarefas correspondentes", + "taskList": "Lista de tarefas", + "selectedCount": "{selected} / {total} selecionados", + "unableOpenPath": "Não foi possível abrir {path}", + "unableLocatePath": "Não foi possível localizar o {path}", + "clearSelection": "Limpar a selecção", + "deselectTask": "Desmarcar", + "selectTask": "Selecionar", + "updateManually": "Atualizar manualmente", + "stopListening": "Pára de ouvir", + "listenForUrl": "Ouvir o URL", + "waitingForReplacementUrl": "Aguardando um URL de substituição", + "taskNotFound": "Tarefa não encontrada", + "storagePath": "Localização do Armazenamento", + "directory": "Pasta", + "mode": "Modo", + "keepOriginalName": "Manter o nome original", + "enterCount": "Digite a contagem", + "chooseDownloadDirectory": "Escolha o diretório de download", + "applicationStorage": "Armazenamento da aplicação", + "applicationStorageDescription": "Armazenamento fiável da aplicação; os ficheiros são removidos ao desinstalar a aplicação", + "publicDownloadsDescription": "Mantém os ficheiros após a desinstalação; a disponibilidade depende do dispositivo Android", + "downloadsDirectoryUnavailable": "O Gopeed não consegue escrever em Download/Gopeed neste dispositivo", + "enterValue": "Digite o valor", + "trackers": "Rastreadores", + "oneTrackerPerLine": "Um URL de rastreador por linha", + "pasteDownloadLinks": "Colar ligações HTTP, HTTPS ou MAGNET, ou soltar um ficheiro torrent aqui", + "unableReadTorrent": "Não foi possível ler o arquivo torrent: {error}", + "torrentOnly": "Apenas os arquivos .torrent são suportados", + "torrentDataEmpty": "Os dados do ficheiro Torrent estão em branco", + "torrentPathUnavailable": "Não foi possível aceder ao local do ficheiro torrent", + "selectAtLeastOneFile": "Selecione pelo menos um arquivo", + "resolvedFiles": "Ficheiros Resolvidos", + "resolvedFileCount": "{count} ficheiros resolvidos", + "resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis", + "noFiles": "Sem arquivos", + "createHistory": "Criar Histórico", + "noHistory": "Sem histórico", + "directDownloadDescription": "Ignorar resolver e criar tarefas imediatamente", + "taskProgress": "Progresso da tarefa", + "updateAndResume": "Atualizar e retomar", + "httpHeaders": "Cabeçalhos HTTP", + "piece": "Peça {number}", + "pieceRange": "Peças {start}–{end}", + "connectionStatus": "Estado da Ligação", + "connectionCount": "{count} ligações", + "retrying": "Repetindo", + "connectionFailed": "A ligação falhou", + "connectionFailedRetries": "A ligação falhou após {count} tentativas", + "retryingCount": "Tentando novamente · {count} tentativas", + "pieceStatus": "Estado da Peça", + "peers": "Persianas", + "seeders": "Semeadores", + "leechers": "Alhos-porros", + "downloadedAmount": "Transferido", + "uploadedAmount": "Enviado", + "shareRatio": "Taxa de Partilha", + "shareDuration": "Duração da partilha", + "connectedPeers": "{count} ligado", + "active": "Activo", + "completedPieces": "Peças Completas", + "activePeers": "{count} activos", + "incomplete": "Incompleto", + "address": "Endereço", + "client": "Cliente", + "pieces": "Peças", + "source": "Origem", + "fileRelevance": "Relevância dos ficheiros", + "taskDetails": "Detalhes da Tarefa", + "taskDetailsInfoTab": "Detalhes", + "taskDetailsFilesTab": "Ficheiros", + "taskDetailsStatsTab": "Estatísticas", + "noStatistics": "Não existem estatísticas disponíveis", + "noPeers": "Sem ligações", + "deleteTasksTitle": "Apagar {count} tarefas?", + "keepFilesDescription": "Remova apenas o registro de tarefas do Gopeed", + "readingRuntimeStatus": "A ler o estado da execução...", + "apiConfigSaved": "Configurações da API salvas. Reinicie o aplicativo para aplicá- las", + "socketPathRequired": "O caminho do 'socket' é necessário", + "serverRequired": "O servidor é necessário", + "portRangeError": "Digite uma porta entre 0 e 65535", + "portInUseValue": "Porto {port} já está em uso", + "backgroundLocationPermissionRequired": "Sempre Permitir acesso de localização é necessário para downloads de fundo", + "scriptPath": "Localização do Programa", + "logDirectoryUnavailable": "O diretório de log não está inicializado", + "unableOpenExternalLink": "Não foi possível abrir o link externo", + "neverUpdated": "Nunca actualizado", + "lastUpdated": "Última actualização: {time}", + "unableLoadSettings": "Não foi possível carregar as configurações", + "fieldRequired": "{field} é necessário", + "addGithubMirror": "Adicionar um Espelho GitHub", + "editGithubMirror": "Editar o Espelho GitHub", + "confirmDeleteTitle": "Confirmar a eliminação?", + "confirmDeleteNamed": "Tem certeza de que deseja excluir “{name}”?", + "categoryFieldsRequired": "Nome e localização da categoria são necessários", + "addCategory": "Adicionar categoria", + "editCategory": "Editar a Categoria", + "deleteCategoryTitle": "Apagar a categoria?", + "updateFailed": "A actualização falhou. Por favor tente novamente mais tarde", + "newVersionAvailable": "Uma nova versão está disponível", + "releaseNotes": "O que há de novo", + "noReleaseNotes": "Nenhuma nota de lançamento disponível", + "preparingDownload": "Preparando a transferência...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Serviço em segundo plano do Gopeed", + "androidForegroundNotificationTitle": "O Gopeed está em execução", + "androidForegroundNotificationText": "Os downloads podem continuar em segundo plano" +} diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb new file mode 100644 index 00000000..48de2625 --- /dev/null +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ru", + "label": "Русский", + "error": "Ошибка", + "tip": "Подсказка", + "confirm": "Подтвердить", + "confirmDelete": "Вы уверены, что хотите удалить?", + "cancel": "Отмена", + "on": "Вкл", + "off": "Откл", + "selectAll": "Выбрать все", + "select": "Выбрать", + "task": "Задачи", + "downloading": "Загрузка", + "downloaded": "Загружено", + "setting": "Настройки", + "donate": "Пожертвовать", + "exit": "Выход", + "pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти", + "create": "Создать задачу", + "directDownload": "Прямая загрузка", + "advancedOptions": "Расширенные опции", + "followSettings": "По умолчанию", + "downloadLink": "Ссылка на скачивание", + "downloadLinkValid": "Пожалуйста, введите ссылку для скачивания", + "downloadLinkHit": "Пожалуйста, введите ссылку для скачивания, HTTP/HTTPS/MAGNET поддерживаются@append", + "downloadLinkHitDesktop": ", или перетащите сюда торрент-файл", + "download": "Скачать", + "noFileSelected": "Файл не выбран", + "noStoragePermission": "Требуется доступ к хранилищу", + "selectFile": "Выбрать файл", + "rename": "Переименовать", + "basic": "Основные параметры", + "advanced": "Расширенные", + "general": "Общие", + "downloadDir": "Папка загрузки", + "rememberLastDownloadDir": "Автоматически сохранять последнюю папку задачи", + "downloadDirValid": "Пожалуйста, выберите папку для загрузки", + "connections": "Соединений", + "useServerCtime": "Использовать время сервера для создания файла", + "maxRunning": "Максимальное количество активных задач", + "defaultDirectDownload": "Прямая загрузка по умолчанию", + "autoStartTasks": "Автоматически запускать незавершённые задачи при старте", + "autoTorrentEnable": "Автоматически создавать BT-задачи из .torrent файлов", + "autoTorrentDeleteAfterDownload": "Удалить .torrent файл после создания BT-задачи", + "autoDeleteMissingFileTasks": "Автоудаление задач с отсутствующими файлами", + "items": "{count} элементов", + "subscribeTracker": "Подпись трекера", + "subscribeFail": "Подписка не удалась, пожалуйста, проверьте сеть или повторите попытку позже", + "update": "Обновить", + "updateDaily": "Обновлять ежедневно", + "lastUpdate": "Последнее обновление: @время", + "addTracker": "Добавить трекер", + "addTrackerHit": "Пожалуйста, введите url трекера, по одному в строке", + "ui": "Интерфейс", + "theme": "Тема", + "themeSystem": "Системная", + "themeLight": "Светлая", + "themeDark": "Тёмная", + "locale": "Язык", + "notifyWhenNewVersion": "Уведомить об обновлениях", + "analyticsEnabled": "Аналитика использования", + "analyticsEnabledDesc": "Поделитесь анонимными данными об использовании, чтобы помочь нам улучшить", + "about": "О приложении", + "homepage": "Домашняя страница", + "version": "Версия", + "protocol": "Протокол", + "port": "Порт", + "apiToken": "API Токен", + "notSet": "Не установлено", + "set": "Установлено", + "portInUse": "Порт [@PORT] уже используется, пожалуйста, измените порт", + "effectAfterRestart": "Эффект после перезагрузки", + "developer": "Разработчик", + "logDirectory": "Каталог журналов", + "webhook": "Web-cook", + "webhookEnable": "Включить Webhook", + "webhookDesc": "Отправка HTTP-уведомлений POST при выполнении или сбое задач", + "webhookUrlHint": "Введите URL webhook", + "webhookTest": "Тест", + "webhookTestSuccess": "Webhook успешно проверен", + "webhookTestFail": "Не удалось проверить webhook", + "script": "сценарий", + "scriptEnable": "Включите сценарий", + "scriptDesc": "Исполняйте пользовательские скрипты, когда загрузка успешно завершается", + "scriptPathHint": "Введите путь файла скрипта (например, /path/to/script.sh)", + "urlInvalid": "Пожалуйста, введите действительный URL HTTP или HTTPS", + "required": "Это поле необходимо", + "show": "Показать", + "continueAction": "Продолжить", + "pause": "Пауза", + "startAll": "Запустить все", + "pauseAll": "Приостановить все", + "deleteTask": "Удалить @count задач", + "deleteTaskTip": "Сохранить загруженные файлы", + "delete": "Удалить", + "add": "Добавить", + "edit": "Редактировать", + "newVersionTitle": "Обнаружена новая версия @version", + "newVersionUpdate": "Обновить", + "newVersionLater": "позже", + "extensions": "Расширения", + "extensionInstallUrl": "URL для установки", + "extensionInstallSuccess": "Установка завершена", + "extensionUpdateSuccess": "Обновление завершено", + "extensionDelete": "Удалить расширение", + "extensionAlreadyLatest": "Это последняя версия", + "extensionFind": "Найти расширения", + "extensionDevelop": "Разработка расширений", + "extensionStoreTitle": "Магазины расширения", + "extensionStoreSection": "Расширения магазина", + "extensionInstalledSection": "Установленные расширения", + "extensionInstalledEmpty": "Расширений пока не установлено", + "extensionStoreEmpty": "Не найдено ни одного соответствующего расширения", + "extensionSearchHint": "Поиск по имени, названию, описанию или автору", + "extensionSortStars": "Большинство звезд", + "extensionSortInstalls": "Большинство инсталляций", + "extensionSortUpdated": "Недавно обновленный", + "extensionSortToggle": "Направление сортировки переключателей", + "extensionLoadMore": "Загрузить больше", + "extensionNoMore": "Больше никаких расширений", + "extensionInstalled": "установленный", + "extensionCanUpdate": "Обновление доступно", + "extensionInstall": "устанавливать", + "extensionLoadLocal": "Установите локальный папку", + "extensionFilterAll": "Все", + "extensionFilterMarket": "рынок", + "extensionFilterInstalled": "установленный", + "extensionManualInstall": "Ручная установка", + "extensionInstallTools": "Установите опции", + "history": "История", + "clearHistory": "Очистить историю", + "noHistoryFound": "История не найдена", + "serviceTitle": "Служба загрузки", + "serviceText": "Работает", + "network": "Сеть", + "proxy": "Прокси", + "noProxy": "Без прокси", + "systemProxy": "Системный прокси", + "customProxy": "Пользовательский прокси", + "server": "Сервер", + "username": "Имя пользователя", + "password": "Пароль", + "thanks": "Благодарности", + "thanksDesc": "Благодарим всех участников за их вклад в строительство и развитие сообщества Gopeed!", + "browserExtension": "Расширение браузера", + "launchAtStartup": "Запускать при загрузке системы", + "runAsMenubarApp": "Запускать как приложение в строке меню", + "runAsMenubarAppDesc": "Скрыть значок в Dock и запускать только в строке меню", + "seedConfig": "Настройка раздачи", + "seedKeep": "Раздавать пока не остановлю вручную", + "seedRatio": "Коэффициент раздачи", + "seedTime": "Время раздачи (минуты)", + "setAsDefaultBtClient": "Установить как клиент BT по умолчанию", + "taskDetail": "Детали задания", + "taskName": "Название задачи", + "taskUrl": "URL задачи", + "downloadPath": "Путь загрузки", + "skipVerifyCert": "Пропустить проверку сертификата", + "archives": "Архивы", + "autoExtract": "Автоматически распаковать архивы", + "archivePassword": "Пароль архива", + "archivePasswordHint": "Оставить пустым, если архив не защищён паролем", + "deleteAfterExtract": "Удалить архив после распаковки", + "extracting": "Распаковка", + "extractDone": "Распаковка завершена", + "extractError": "Ошибка распаковки", + "waitingParts": "Ожидание частей", + "name": "Название", + "size": "Размер", + "unknown": "Неизвестно", + "fileSelectedCount": "Файлов: ", + "fileSelectedSize": "Размер: ", + "httpHeader": "Заголовок", + "httpHeaderName": "Название заголовка HTTP", + "httpHeaderValue": "Значение заголовка HTTP", + "login": "Логин", + "username_required": "Пожалуйста, введите имя пользователя", + "password_required": "Пожалуйста, введите пароль", + "login_success": "Логин успешный", + "login_failed": "Логин не удалось, пожалуйста, проверьте свое имя пользователя и пароль", + "login_failed_network": "Логин не удалось, пожалуйста, проверьте ваше сетевое соединение", + "insertPlaceholder": "Вставить заполнитель", + "placeholderYear": "Текущий год", + "placeholderMonth": "Текущий месяц (01-12)", + "placeholderDay": "Текущий день (01-31)", + "placeholderDate": "Полная дата (ГГГГ-ММ-ДД)", + "example": "напр. @value", + "downloadCategories": "Категории загрузки", + "categoryMusic": "Музыка", + "categoryVideo": "Видео", + "categoryDocument": "Документ", + "categoryProgram": "Программа", + "categoryName": "Название категории", + "categoryPath": "Путь категории", + "builtInCategory": "Встроенную категорию нельзя удалить", + "selectCategory": "Выбрать категорию", + "quickDirectories": "Быстрые папки", + "githubMirror": "Зеркало GitHub", + "githubMirrorEnable": "Включите GitHub Mirror", + "githubMirrorDesc": "Используйте зеркала для ускорения загрузки контента GitHub", + "githubMirrorType": "Тип зеркала", + "githubMirrorUrl": "Зеркальный URL", + "githubMirrorUrlHint": "Введите зеркальный URL", + "updateUrl": "Обновление URL", + "updateUrlManual": "Ручное обновление", + "updateUrlListen": "Слушайте обновление", + "updateUrlListeningTip": "В ожидании обновления URL", + "updateUrlCancelListen": "Отменить прослушивание", + "updateUrlDialogHint": "Введите новый URL загрузки", + "pendingUpdateFound": "Найдена задача обновления", + "pendingUpdateConfirm": "Задание \"{name}\" ждет обновления URL. Хотите обновить его новым URL?", + "pendingUpdateYes": "Обновление задачи", + "pendingUpdateNo": "Создать новую задачу", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP Listen Port", + "ed2kUdpPort": "UDP Listen Port", + "ed2kServerList": "Адреса сервера", + "ed2kServerMet": "Server.met Источники", + "ed2kNodesDat": "Nodes.dat Источники", + "ed2kAutoPort": "Автоматический", + "ed2kOnePerLine": "Один вход на линию", + "ed2kServersHint": "хост: порт, по одной линии", + "ed2kServerMetHint": "URL-адрес Server.met или ссылка на серверный список ed2k, по одной строке", + "ed2kNodesDatHint": "nodes.dat path или URL, по одному на строку", + "desktopNotification": "Уведомления", + "backgroundLocationKeepAlive": "Фоновая загрузка", + "notificationTaskDone": "Задача завершена", + "notificationTaskError": "Ошибка задачи", + "appTitle": "Менеджер загрузок Gopeed", + "unsupportedWindowPayload": "Неподдерживаемый запрос на окно", + "runtimeInitializationFailed": "Не удалось инициализировать среду выполнения Gopeed.\n{error}", + "save": "Спасти", + "retry": "повторный", + "close": "Закрыть", + "back": "Назад", + "open": "Открыть", + "openFile": "Открыть файл", + "share": "Поделиться", + "copy": "Копия", + "copied": "Копирован", + "refresh": "освежиться", + "status": "Статус", + "createdAt": "Время создания", + "speed": "Скорость", + "uploadSpeed": "Скорость загрузки", + "remaining": "оставаться", + "downloadDuration": "Время загрузки", + "progress": "Прогресс", + "failed": "неудачник", + "completed": "завершенный", + "waiting": "Ждать", + "taskFailed": "Задание провалилось", + "unknownSize": "Неизвестный размер", + "secondsRemaining": "Осталось {count} секунды", + "minutesRemaining": "Осталось {count} минуты", + "hoursRemaining": "Осталось {count} часа", + "secondsDuration": "{count} секунд", + "minutesDuration": "{count} минут", + "hoursDuration": "{count} часов", + "followSystem": "Системные настройки", + "basicSettings": "Основные настройки", + "downloadSettings": "Настройки загрузки", + "advancedSettings": "Расширенные настройки", + "themeColor": "Тематический цвет", + "themeColorDescription": "Настройка цвета акцента приложения", + "browserExtensionDescription": "Отправьте загрузку браузера непосредственно в Gopeed после установки", + "desktopNotificationDescription": "Показать системное уведомление, когда задача завершается или выходит из строя", + "backgroundLocationKeepAliveDescription": "Используйте доступ к местоположению, чтобы поддерживать активные загрузки в фоновом режиме", + "currentVersion": "Версия {version}", + "contributors": "Вкладчики", + "viewContributors": "Посмотреть вкладчики", + "maxRunningDescription": "Максимальное количество задач, которые можно скачать одновременно", + "defaultUserAgentDescription": "Пользователь-агент по умолчанию для загрузки HTTP и HTTPS", + "listenPort": "Порт прослушивания", + "onePerLine": "Один вход на линию", + "proxyProtocol": "Прокси-протокол", + "restartRequired": "Эта настройка вступает в силу после перезапуска приложения", + "socketPath": "Путь розетки", + "listenAddress": "Слушайте адрес", + "currentConnection": "Текущая связь", + "webhookPush": "Конечные точки Webhook", + "webhookPushDescription": "Отправляйте уведомления HTTP POST этим конечным точкам, когда задачи завершаются или выходят из строя", + "scriptExecution": "Сценарии", + "scriptExecutionDescription": "Запустите эти скрипты после успешного завершения загрузки", + "addWebhook": "Добавить Webhook", + "editWebhook": "Редактировать Webhook", + "addScript": "Добавить сценарий", + "editScript": "Сценарий Edit", + "openDirectory": "Открытый каталог", + "browseFiles": "Просмотреть файлы", + "updateEveryDay": "Обновление ежедневно", + "updateVersion": "Обновление", + "checkForUpdates": "Проверьте обновления", + "updating": "Обновление...", + "colorGreen": "Зеленый", + "colorRed": "красный", + "colorRose": "Роза", + "colorOrange": "апельсиновый", + "colorCyan": "Сиан", + "colorBlue": "голубой", + "colorAmber": "Эмбер", + "colorPurple": "фиолетовый", + "noExtensions": "Никаких расширений", + "searchExtensions": "Расширения поиска...", + "extensionDeveloperGuide": "Руководство для разработчиков Extension", + "extensionInstallFromUrl": "Установите расширение с URL", + "unableOpenExtensionDocs": "Невозможность открыть руководство для разработчиков расширений", + "removeExtensionTitle": "Удалить расширение?", + "removeExtensionConfirm": "Вы уверены, что хотите удалить «{name}»?", + "uninstall": "Удалить", + "unableLoadExtensions": "Невозможно загрузить расширения", + "filters": "Фильтры", + "allTasks": "Все задачи", + "searchDownloads": "Поисковые загрузки...", + "searchHistory": "Поиск по истории…", + "addTask": "Добавить задачу", + "emptyTaskList": "Задачи в этом списке отсутствуют", + "noMatchingTasks": "Не найдено ни одной подходящей задачи", + "taskList": "Список задач", + "selectedCount": "{selected} / {total} выбрано", + "unableOpenPath": "Невозможно открыть {path}", + "unableLocatePath": "Не удалось найти {path}", + "clearSelection": "Четкая выборка", + "deselectTask": "Снять выделение", + "selectTask": "Выбрать", + "updateManually": "Обновление вручную", + "stopListening": "Хватит слушать", + "listenForUrl": "Слушайте URL", + "waitingForReplacementUrl": "В ожидании замены URL", + "taskNotFound": "Задание не найдено", + "storagePath": "Путь хранения", + "directory": "Директория", + "mode": "Режим", + "keepOriginalName": "Сохраните оригинальное имя", + "enterCount": "Введите счет", + "chooseDownloadDirectory": "Выберите директорию загрузки", + "applicationStorage": "Хранилище приложения", + "applicationStorageDescription": "Надёжное хранилище приложения; файлы удаляются при удалении приложения", + "publicDownloadsDescription": "Сохраняет файлы после удаления приложения; доступность зависит от устройства Android", + "downloadsDirectoryUnavailable": "Gopeed не может записывать в Download/Gopeed на этом устройстве", + "enterValue": "Ввести ценность", + "trackers": "Следопыты", + "oneTrackerPerLine": "Один трекер URL в строке", + "pasteDownloadLinks": "Вставьте ссылки HTTP, HTTPS или MAGNET или выпустите торрент-файл здесь", + "unableReadTorrent": "Невозможность чтения торрент-файла: {error}", + "torrentOnly": "Поддерживаются только .torrent файлы", + "torrentDataEmpty": "Данные торрент-файлов пусты", + "torrentPathUnavailable": "Невозможность доступа к торрент-файлу", + "selectAtLeastOneFile": "Выберите хотя бы один файл", + "resolvedFiles": "Решенные файлы", + "resolvedFileCount": "{count} файлов, разрешенных", + "resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов", + "noFiles": "Нет файлов", + "createHistory": "Создаем историю", + "noHistory": "Нет истории", + "directDownloadDescription": "Немедленно пропустите решение и создайте задачи", + "taskProgress": "Прогресс в решении задач", + "updateAndResume": "Обновление и возобновление", + "httpHeaders": "HTTP-заголовки", + "piece": "Кусочек {number}", + "pieceRange": "Куски {start}–{end}", + "connectionStatus": "Статус подключения", + "connectionCount": "{count} соединения", + "retrying": "повторный", + "connectionFailed": "Подключение не удалось", + "connectionFailedRetries": "Не удалось подключиться после {count} попыток", + "retryingCount": "Повторная попытка · {count}", + "pieceStatus": "Статус Писания", + "peers": "пэры", + "seeders": "Сеятели", + "leechers": "Личерс", + "downloadedAmount": "Загруженный", + "uploadedAmount": "Загруженный", + "shareRatio": "Коэффициент раздачи", + "shareDuration": "Время раздачи", + "connectedPeers": "{count} подключено", + "active": "Активный", + "completedPieces": "Завершенные пьесы", + "activePeers": "{count} активные", + "incomplete": "неполный", + "address": "Адрес", + "client": "клиент", + "pieces": "Куски", + "source": "Источник", + "fileRelevance": "Релевантность файлов", + "taskDetails": "Подробности задания", + "taskDetailsInfoTab": "Подробности", + "taskDetailsFilesTab": "Файлы", + "taskDetailsStatsTab": "Статистика", + "noStatistics": "Нет доступных статистических данных", + "noPeers": "Нет никаких связей", + "deleteTasksTitle": "Удалить {count} задачи?", + "keepFilesDescription": "Удалить только запись задачи из Gopeed", + "readingRuntimeStatus": "Статус чтения Runtime...", + "apiConfigSaved": "Сохранены настройки API. Перезагрузите приложение, чтобы применить их", + "socketPathRequired": "Требуется Socket Path", + "serverRequired": "Сервер необходим", + "portRangeError": "Введите порт между 0 и 65535", + "portInUseValue": "Порт {port} уже используется", + "backgroundLocationPermissionRequired": "Всегда разрешайте доступ к местоположению для фоновых загрузок", + "scriptPath": "Сценарий Путь", + "logDirectoryUnavailable": "Каталог журналов не инициализируется", + "unableOpenExternalLink": "Невозможность открыть внешнюю ссылку", + "neverUpdated": "Никогда не обновлялся", + "lastUpdated": "Последнее обновление: {time}", + "unableLoadSettings": "Невозможно загрузить настройки", + "fieldRequired": "{field} требуется", + "addGithubMirror": "Добавить GitHub Mirror", + "editGithubMirror": "Зеркало GitHub Mirror", + "confirmDeleteTitle": "Подтверждает удаление?", + "confirmDeleteNamed": "Вы уверены, что хотите удалить «{name}»?", + "categoryFieldsRequired": "Требуется название категории и путь", + "addCategory": "Добавить категорию", + "editCategory": "Категория редактирования", + "deleteCategoryTitle": "Удалить категорию?", + "updateFailed": "Обновление не удалось. Пожалуйста, попробуйте еще раз позже", + "newVersionAvailable": "Доступна новая версия", + "releaseNotes": "Что нового", + "noReleaseNotes": "Нет доступных записей о выпуске", + "preparingDownload": "Готовим скачать...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Фоновая служба Gopeed", + "androidForegroundNotificationTitle": "Gopeed работает", + "androidForegroundNotificationText": "Загрузки могут продолжаться в фоновом режиме" +} diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb new file mode 100644 index 00000000..a9f0b6c0 --- /dev/null +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ta", + "label": "தமிழ்", + "error": "பிழை", + "tip": "குறிப்பு", + "confirm": "சரி", + "confirmDelete": "நிச்சயமாக நீக்க விரும்புகிறீர்களா?", + "cancel": "ரத்துசெய்", + "on": "மீது", + "off": "நிறுத்துக", + "selectAll": "அனைத்தையும் தேர்வுசெய்", + "select": "தேர்ந்தெடுக்கவும்", + "task": "பணிகள்", + "downloading": "பதிவிறக்குகிறது", + "downloaded": "பதிவிறக்கம் செய்யப்பட்டது", + "setting": "அமைப்புகள் ", + "donate": "நன்கொடை", + "exit": "வெளியேறு", + "pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்", + "create": "பணியை உருவாக்கு", + "directDownload": "நேரடி பதிவிறக்கம்", + "advancedOptions": "மேம்பட்ட விருப்பங்கள்", + "followSettings": "அமைப்புகளைப் பின்தொடரவும்", + "downloadLink": "தரவிறக்க இணைப்பு", + "downloadLinkValid": "பதிவிறக்க இணைப்பை உள்ளிடவும்", + "downloadLinkHit": "பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append", + "downloadLinkHitDesktop": ", அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்", + "download": "பதிவிறக்கு", + "noFileSelected": "குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்", + "noStoragePermission": "சேமிப்பக அனுமதி தேவை", + "selectFile": "கோப்பைத் தேர்ந்தெடுக்கவும்", + "rename": "பெயறைமாற்று", + "basic": "அடிப்படை", + "advanced": "மேம்பட்ட", + "general": "பொதுவான", + "downloadDir": "பதிவிரக்குமிடம்", + "rememberLastDownloadDir": "கடைசி பணி கோப்பகத்தைத் தானாகச் சேமி", + "downloadDirValid": "பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்", + "connections": "தொடர்புகள்", + "useServerCtime": "கோப்பு உருவாக்க சேவையக நேரத்தைப் பயன்படுத்தவும்", + "maxRunning": "அதிகபட்ச இயங்கும் பணிகள்", + "defaultDirectDownload": "இயல்புநிலையாக நேரடிப் பதிவிறக்கத்தைச் சரிபார்க்கவும்", + "autoStartTasks": "தொடக்கத்தில் முழுமையடையாத பணிகளைத் தானாகத் தொடங்குங்கள்", + "autoTorrentEnable": ".torrent கோப்புகளிலிருந்து BT பணிகளை தானாக உருவாக்கு", + "autoTorrentDeleteAfterDownload": "BT பணி உருவாக்கத்திற்குப் பிறகு .torrent கோப்பை நீக்கு", + "autoDeleteMissingFileTasks": "காணாமல் போன கோப்புகளுடன் பணிகளை தானாக நீக்கு", + "items": "{count} உருப்படிகள்", + "subscribeTracker": "டிராக்கரை குழுசேரவும்", + "subscribeFail": "சந்தா தோல்வியுற்றது, நெட்வொர்க்கைச் சரிபார்க்கவும் அல்லது பின்னர் மீண்டும் முயற்சிக்கவும்", + "update": "புதுப்பிக்கவும்", + "updateDaily": "தினமும் புதுப்பிக்கவும்", + "lastUpdate": "கடைசியாக புதுப்பிக்கப்பட்டது: @time", + "addTracker": "Trackerஐ சேர்", + "addTrackerHit": "டிராக்கர் சேவையக URL ஐ உள்ளிடவும், ஒவ்வொரு வரியிலும் ஒன்று", + "ui": "செய்வதற்குரிய வகைகளை காண்பிக்க வேண்டியதைத் தேர்ந்தெடு", + "theme": "தீம்", + "themeSystem": "இயல்புநிலை", + "themeLight": "வெண்மையானநிலை", + "themeDark": "இருண்டநிலை", + "locale": "மொழி", + "notifyWhenNewVersion": "புதுப்பிப்புகளுக்கு அறிவிக்கவும்", + "analyticsEnabled": "புள்ளிவிவரங்களைப் பதிவேற்றவும்", + "analyticsEnabledDesc": "மேம்படுத்த எங்களுக்கு உதவ அநாமதேய பயன்பாட்டுத் தரவைப் பகிரவும்", + "about": "எங்களை பற்றி", + "homepage": "முகப்புப்பக்கம்", + "version": "பதிப்பு", + "protocol": "நெறிமுறை", + "port": "துறைமுகம்", + "apiToken": "API டோக்கன்", + "notSet": "& கெல்வினை பயன்படுத்தவும்", + "set": "நீங்கள் என்ன நினைக்கிறீர்கள் என்பதை அமைக்கவும்", + "portInUse": "போர்ட் [@port] பயன்பாட்டில் உள்ளது, தயவுசெய்து போர்ட்டை மாற்றவும்", + "effectAfterRestart": "மறுதொடக்கம் செய்த பிறகு விளைவு", + "developer": "உருவாக்குபவர்", + "logDirectory": "பதிவு கோப்பகம்", + "webhook": "வெப்ஹூக்", + "webhookEnable": "வெப்ஹூக்கை இயக்கு", + "webhookDesc": "பணிகள் முடிவடையும் போது அல்லது தோல்வியடையும் போது HTTP இடுகை அறிவிப்புகளை அனுப்பவும்", + "webhookUrlHint": "வெப்ஹூக் URL ஐ உள்ளிடவும்", + "webhookTest": "& மீட்டமை", + "webhookTestSuccess": "வெப்ஹூக் சோதனை வெற்றிகரமாக முடிந்தது", + "webhookTestFail": "வெப்ஹூக் சோதனை தோல்வியடைந்தது", + "script": "சிறுநிரல்", + "scriptEnable": "ஸ்கிரிப்டை இயக்கு", + "scriptDesc": "பதிவிறக்கங்கள் வெற்றிகரமாக முடிந்ததும் தனிப்பயன் ஸ்கிரிப்ட்களை இயக்கவும்", + "scriptPathHint": "ஸ்கிரிப்ட் கோப்பு பாதையை உள்ளிடவும் (எ.கா., /பாதை/க்கு/script.sh)", + "urlInvalid": "சரியான HTTP அல்லது HTTPS URL ஐ உள்ளிடவும்", + "required": "\"இந்த புலம் தேவை\",", + "show": "காட்டு", + "continueAction": "தொடரவும்", + "pause": "இடைநிறுத்து", + "startAll": "அனைத்தையும் தொடங்கு", + "pauseAll": "அனைத்தையும் நிறுத்திவை", + "deleteTask": "பணிகளை நீக்கு @count", + "deleteTaskTip": "பதிவிறக்கம் செய்யப்பட்ட கோப்புகளை வைத்திரு", + "delete": "நீக்கு", + "add": "சேர்", + "edit": "திருத்துக", + "newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version", + "newVersionUpdate": "இப்பொழுது புதுப்பி", + "newVersionLater": "பின்னர்", + "extensions": "நீட்டிப்புகள்", + "extensionInstallUrl": "நிறுவப்படும் URL", + "extensionInstallSuccess": "வெற்றிகரமாக நிறுவப்பட்டது", + "extensionUpdateSuccess": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "extensionDelete": "நீட்டிப்பை நீக்கு", + "extensionAlreadyLatest": "இது ஏற்கனவே சமீபத்திய பதிப்பாகும்", + "extensionFind": "நீட்டிப்புகளைக் கண்டறியவும்", + "extensionDevelop": "நீட்டிப்புகளை உருவாக்கவும்", + "extensionStoreTitle": "எக்ஸ்டென்ஷன் ஸ்டோர்", + "extensionStoreSection": "ஸ்டோர் நீட்டிப்புகள்", + "extensionInstalledSection": "நிறுவப்பட்ட நீட்டிப்புகள்", + "extensionInstalledEmpty": "நீட்டிப்புகள் எதுவும் இன்னும் நிறுவப்படவில்லை", + "extensionStoreEmpty": "பொருந்தக்கூடிய நீட்டிப்புகள் எதுவும் காணப்படவில்லை", + "extensionSearchHint": "பெயர், தலைப்பு, விளக்கம் அல்லது ஆசிரியர் மூலம் தேடவும்", + "extensionSortStars": "பெரும்பாலான நட்சத்திரங்கள்", + "extensionSortInstalls": "பெரும்பாலான நிறுவல்கள்", + "extensionSortUpdated": "சமீபத்தில் புதுப்பிக்கப்பட்டது", + "extensionSortToggle": "வரிசைப்படுத்தல் திசையை மாற்றவும்", + "extensionLoadMore": "அதிகமாக ஏற்றவும்", + "extensionNoMore": "மேலும் நீட்டிப்புகள் இல்லை", + "extensionInstalled": "நிறுவப்பட்டது", + "extensionCanUpdate": "புதிய புதுப்பிப்பு கிடைக்கிறது!", + "extensionInstall": "நிறுவுக", + "extensionLoadLocal": "உள்ளமை கோப்புறையை நிறுவுக", + "extensionFilterAll": "உங்கள் அனைவருக்கும் இனிய தீபாவளி நல்வாழ்த்துக்கள்", + "extensionFilterMarket": "மெட்டா", + "extensionFilterInstalled": "நிறுவப்பட்டது", + "extensionManualInstall": "கைமுறையாக நிறுவவும்", + "extensionInstallTools": "விருப்பங்களை நிறுவவும்", + "history": "வரலாறு", + "clearHistory": "உள்ளீடுகளை நீக்கு", + "noHistoryFound": "உள்ளீடுகள் இல்லை", + "serviceTitle": "பதிவிறக்க சேவை", + "serviceText": "இயக்கத்தில் உள்ளது", + "network": "வலைப்பின்னல்", + "proxy": "பதிலி", + "noProxy": "பதிலி இல்லாமல் ", + "systemProxy": "இயல்புநிலை பதிலி", + "customProxy": "மாற்றியமைக்கப்பட்ட பதிலி", + "server": "சேவையகம்", + "username": "பயனர் பெயர்", + "password": "கடவுச்சொல்", + "thanks": "நன்றி", + "thanksDesc": "Gopeed சமூகத்தை உருவாக்கவும் மற்றும் மேம்படுத்த உதவிய அனைத்து பங்களிப்பாளர்களுக்கும் நன்றி!", + "browserExtension": "உலாவி நீட்டிப்பு", + "launchAtStartup": "தொடக்கத்தின் போது இயங்கு", + "runAsMenubarApp": "மெனுபார் பயன்பாடாக இயக்கு", + "runAsMenubarAppDesc": "டாக் ஐகானை மறைத்து மெனுபாரில் மட்டும் இயக்கு", + "seedConfig": "விதை கட்டமைப்பு", + "seedKeep": "கைமுறையாக நிறுத்தப்படும் வரை விதைப்பதைத் தொடரவும்", + "seedRatio": "விதை விகிதம்", + "seedTime": "விதை நேரம் (நிமிடங்கள்)", + "setAsDefaultBtClient": "இயல்புநிலை BT கிளையண்டாக அமைக்கவும்", + "taskDetail": "பணி விவரம்", + "taskName": "செயல் பெயர்", + "taskUrl": "பணி URL", + "downloadPath": "பதிவிறக்க பாதை", + "skipVerifyCert": "திருத்தி சான்று சரிபார்க்கவும்", + "archives": "காப்பகங்கள்", + "autoExtract": "காப்பகங்களை தானாக பிரித்தெடு", + "archivePassword": "காப்பக கடவுச்சொல்", + "archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்", + "deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு", + "extracting": "பிரித்தெடுக்கிறது", + "extractDone": "பிரித்தெடுத்தல் முடிந்தது", + "extractError": "பிரித்தெடுத்தல் தோல்வியடைந்தது", + "waitingParts": "பாகங்களுக்காக காத்திருக்கிறது", + "name": "பெயர்", + "size": "அளவு", + "unknown": "தெரியவில்லை", + "fileSelectedCount": "கோப்பினை திற", + "fileSelectedSize": "அளவு:", + "httpHeader": "மேல் குறிப்பு", + "httpHeaderName": "தலைப்பு பெயர்", + "httpHeaderValue": "தலைப்பு மதிப்பு", + "login": "உள்நுழையவும்", + "username_required": "ஒரு பாவனையாளர் பெயரை உள்ளிடுங்கள்", + "password_required": "தயவு செய்து கடவுச்சொல்லை உள்ளிடவும்", + "login_success": "உள்நுழைவு வெற்றிகரமாக முடிந்தது", + "login_failed": "உள்நுழைவு தோல்வியுற்றது, உங்கள் பயனர்பெயர் மற்றும் கடவுச்சொல்லைச் சரிபார்க்கவும்", + "login_failed_network": "உள்நுழைவு தோல்வியுற்றது, உங்கள் நெட்வொர்க் இணைப்பைச் சரிபார்க்கவும்", + "insertPlaceholder": "இடங்காட்டி செருக", + "placeholderYear": "தற்போதைய ஆண்டு", + "placeholderMonth": "தற்போதைய மாதம் (01-12)", + "placeholderDay": "தற்போதைய நாள் (01-31)", + "placeholderDate": "முழு தேதி (YYYY-MM-DD)", + "example": "எ.கா. @value", + "downloadCategories": "பதிவிறக்க வகைகள்", + "categoryMusic": "இசை", + "categoryVideo": "காணொளி", + "categoryDocument": "ஆவணம்", + "categoryProgram": "நிரல்", + "categoryName": "வகை பெயர்", + "categoryPath": "வகை பாதை", + "builtInCategory": "உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது", + "selectCategory": "வகையைத் தேர்ந்தெடுக்கவும்", + "quickDirectories": "விரைவு கோப்புறைகள்", + "githubMirror": "கிட்ஹப் மிரர்", + "githubMirrorEnable": "கிட்ஹப் கண்ணாடியை இயக்கு", + "githubMirrorDesc": "GitHub உள்ளடக்க பதிவிறக்கங்களை துரிதப்படுத்த கண்ணாடிகளைப் பயன்படுத்தவும்", + "githubMirrorType": "கண்ணாடி வகை", + "githubMirrorUrl": "மிரர் URL", + "githubMirrorUrlHint": "கண்ணாடி URL ஐ உள்ளிடவும்", + "updateUrl": "URL ஐப் புதுப்பிக்கவும்", + "updateUrlManual": "கைமுறை புதுப்பிப்பு", + "updateUrlListen": "புதுப்பிப்பைக் கேளுங்கள்", + "updateUrlListeningTip": "URL புதுப்பிப்புக்காகக் காத்திருக்கிறது", + "updateUrlCancelListen": "கேட்பதை ரத்துசெய்", + "updateUrlDialogHint": "புதிய பதிவிறக்க URL ஐ உள்ளிடவும்", + "pendingUpdateFound": "நிலுவையில் உள்ள புதுப்பிப்பு பணி கண்டறியப்பட்டது", + "pendingUpdateConfirm": "URL புதுப்பிப்புக்காக \"{name}\" பணி காத்திருக்கிறது. புதிய URL உடன் அதைப் புதுப்பிக்க விரும்புகிறீர்களா?", + "pendingUpdateYes": "பணியைப் புதுப்பிக்கவும்", + "pendingUpdateNo": "புதுப்பணியையோ மாறியையோ உருவாக்கு", + "ed2k": "ED2K", + "ed2kTcpPort": "தொலைநிலை", + "ed2kUdpPort": "தொலைநிலை", + "ed2kServerList": "சேவையக முகவரிகள்", + "ed2kServerMet": "Server.met மூலங்கள்", + "ed2kNodesDat": "nodes.dat மூலங்கள்", + "ed2kAutoPort": "தானியங்கு ஒதுக்கீடு", + "ed2kOnePerLine": "ஒரு வரிக்கு ஒரு நுழைவு", + "ed2kServersHint": "ஹோஸ்ட்:போர்ட், ஒரு வரிக்கு ஒன்று", + "ed2kServerMetHint": "Server.met URL அல்லது ed2k சேவையகப் பட்டியல் இணைப்பு, ஒரு வரியில் ஒன்று", + "ed2kNodesDatHint": "nodes.dat பாதை அல்லது URL, ஒரு வரியில் ஒன்று", + "desktopNotification": "கணினி அறிவிப்புகள்", + "backgroundLocationKeepAlive": "பேக்ரௌண்ட் பதிவிறக்கம் உயிருடன் இருங்கள்", + "notificationTaskDone": "பணி முடிந்தது", + "notificationTaskError": "பணி பிழை", + "appTitle": "Gopeed பதிவிறக்க மேலாளர்", + "unsupportedWindowPayload": "ஆதரிக்கப்படாத சாளரக் கோரிக்கை", + "runtimeInitializationFailed": "கோபீட் ரன்டைமை துவக்க முடியவில்லை.\n{error}", + "save": "சேமி", + "retry": "மீண்டும் முயற்சிக்கவும்", + "close": "மூடு", + "back": "பின்செல்", + "open": "திற", + "openFile": "கோப்பைத் திற", + "share": "பகிர்", + "copy": "நகலெடு", + "copied": "நகலெடுக்கப்பட்டது", + "refresh": "புதுப்பி", + "status": "நிலை", + "createdAt": "உருவாக்கிய நேரம்", + "speed": "வேகம்", + "uploadSpeed": "வேகம்", + "remaining": "மீதம்", + "downloadDuration": "பதிவிறக்க நேரம்", + "progress": "முன்னேற்றம்", + "failed": "தோல்வியடைந்தது", + "completed": "முடிவடைந்தது", + "waiting": "காத்திருக்கிறது", + "taskFailed": "பணி தோல்வியுற்றது", + "unknownSize": "அறியப்டாத அளவு@ info: status Free disk space", + "secondsRemaining": "{count} விநாடிகள் மீதமுள்ளன", + "minutesRemaining": "{count} நிமிடங்கள் மீதமுள்ளன", + "hoursRemaining": "{count} மணிநேரம் மீதமுள்ளது", + "secondsDuration": "{count} விநாடிகள்", + "minutesDuration": "{count} நிமிடங்கள்", + "hoursDuration": "{count} மணிநேரம்", + "followSystem": "சிஸ்டத்தைப் பின்தொடரவும்", + "basicSettings": "அடிப்படை அமைப்புகள்", + "downloadSettings": "பதிவிறக்க அமைப்புகள்", + "advancedSettings": "மேம்படுத்தப்பட்ட அமைப்புகள்", + "themeColor": "தீம் வண்ணம்", + "themeColorDescription": "ஆப்பின் உச்சரிப்பு நிறத்தைத் தனிப்பயனாக்கவும்", + "browserExtensionDescription": "நிறுவலுக்குப் பிறகு உலாவி பதிவிறக்கங்களை நேரடியாக Gopeed க்கு அனுப்பவும்", + "desktopNotificationDescription": "ஒரு பணி முடிவடையும் போது அல்லது தோல்வியடையும் போது கணினி அறிவிப்பைக் காட்டவும்", + "backgroundLocationKeepAliveDescription": "பின்னணியில் பதிவிறக்கங்களை செயலில் வைத்திருக்க இருப்பிட அணுகலைப் பயன்படுத்தவும்", + "currentVersion": "தற்போதைய பதிப்பு {version}", + "contributors": "பங்களிப்போர்:", + "viewContributors": "பங்களிப்பாளர்களைக் காண்க", + "maxRunningDescription": "ஒரே நேரத்தில் பதிவிறக்கம் செய்யக்கூடிய அதிகபட்ச பணிகளின் எண்ணிக்கை", + "defaultUserAgentDescription": "HTTP மற்றும் HTTPS பதிவிறக்கங்களுக்கான இயல்புநிலை பயனர்-ஏஜென்ட்", + "listenPort": "தொலைநிலை", + "onePerLine": "ஒரு வரிக்கு ஒரு நுழைவு", + "proxyProtocol": "ப்ராக்ஸி நெறிமுறை", + "restartRequired": "ஆப்பை மறுதொடக்கம் செய்த பிறகு இந்த அமைப்பு நடைமுறைக்கு வரும்", + "socketPath": "சாக்கெட் பாதை", + "listenAddress": "முகவரியைக் கேளுங்கள்", + "currentConnection": "மின்கம்பி சுருள் சுற்றுதல்", + "webhookPush": "வெப்ஹூக் எண்ட்பாயிண்ட்ஸ்", + "webhookPushDescription": "பணிகள் நிறைவடையும்போது அல்லது தோல்வியடையும் போது இந்த இறுதிப் புள்ளிகளுக்கு HTTP இடுகை அறிவிப்புகளை அனுப்பவும்", + "scriptExecution": "எழுத்து வகை", + "scriptExecutionDescription": "பதிவிறக்கம் வெற்றிகரமாக முடிந்த பிறகு இந்த ஸ்கிரிப்ட்களை இயக்கவும்", + "addWebhook": "வெப்ஹூக்கைச் சேர்க்கவும்", + "editWebhook": "வெப்ஹூக்கைத் திருத்தவும்", + "addScript": "நேர்நிரலை சேர்க்க...", + "editScript": "தொகு...", + "openDirectory": "அடைவை திறQuery", + "browseFiles": "கோப்புகளை உலாவு", + "updateEveryDay": "தினமும் புதுப்பிக்கவும்", + "updateVersion": "புதுப்பிக்கவும்", + "checkForUpdates": "புதுப்பிப்புகளைத் தேடவும்", + "updating": "புதுப்பிக்கப்படுகிறது...", + "colorGreen": "பச்சை", + "colorRed": "சிவப்பு", + "colorRose": "உரோசா", + "colorOrange": "ஆரஞ்சு", + "colorCyan": "அச்சு அளவு", + "colorBlue": "நீலம்", + "colorAmber": "அம்பர்", + "colorPurple": "பர்பில் நிறம்", + "noExtensions": "நீட்டிப்புகள் இல்லை", + "searchExtensions": "நீட்டிப்புகளைத் தேடுங்கள்...", + "extensionDeveloperGuide": "நீட்டிப்பு டெவலப்பர் வழிகாட்டி", + "extensionInstallFromUrl": "URL இலிருந்து நீட்டிப்பை நிறுவவும்", + "unableOpenExtensionDocs": "நீட்டிப்பு டெவலப்பர் வழிகாட்டியைத் திறக்க முடியவில்லை", + "removeExtensionTitle": "நீட்டிப்பை நிறுவல் நீக்கவா?", + "removeExtensionConfirm": "\"{name}\" ஐ நிச்சயமாக நிறுவல் நீக்க விரும்புகிறீர்களா?", + "uninstall": "நீக்குக", + "unableLoadExtensions": "நீட்டிப்புகளை ஏற்ற முடியவில்லை", + "filters": "வடிக்கட்டிகள்", + "allTasks": "அனைத்து பணிகளையும் நகலெடு", + "searchDownloads": "பதிவிறக்கங்களைத் தேடுங்கள்...", + "searchHistory": "வரலாற்றைத் தேடு…", + "addTask": "பணியைச் சேர்க்கவும்", + "emptyTaskList": "இந்தப் பட்டியலில் பணிகள் எதுவும் இல்லை", + "noMatchingTasks": "பொருந்தக்கூடிய பணிகள் எதுவும் காணப்படவில்லை", + "taskList": "பணி பட்டியல்", + "selectedCount": "{selected} / {total} தேர்ந்தெடுக்கப்பட்டது", + "unableOpenPath": "{path} ஐத் திறக்க முடியவில்லை", + "unableLocatePath": "{path} ஐக் கண்டுபிடிக்க முடியவில்லை", + "clearSelection": "தேர்ந்தெடுக்கப்பட்ட துடைSearch finished", + "deselectTask": "தேர்வுநீக்கு", + "selectTask": "தேர்ந்தெடு", + "updateManually": "கைமுறையாகப் புதுப்பிக்கவும்", + "stopListening": "கேட்பதை நிறுத்துங்கள்", + "listenForUrl": "URL ஐக் கேளுங்கள்", + "waitingForReplacementUrl": "மாற்று URL-க்காகக் காத்திருக்கிறது", + "taskNotFound": "காணவில்லை", + "storagePath": "சேமிப்பக பாதை", + "directory": "கோப்பகம்", + "mode": "முறைமை", + "keepOriginalName": "மூலப்பெயர்", + "enterCount": "எண்ணிக்கையை உள்ளிடவும்", + "chooseDownloadDirectory": "பதிவிறக்க அடைவைத் தேர்வுசெய்க", + "applicationStorage": "பயன்பாட்டு சேமிப்பகம்", + "applicationStorageDescription": "நம்பகமான பயன்பாட்டு சேமிப்பகம்; பயன்பாட்டை நிறுவல் நீக்கும்போது கோப்புகள் நீக்கப்படும்", + "publicDownloadsDescription": "நிறுவல் நீக்கிய பின்னும் கோப்புகளை வைத்திருக்கும்; கிடைக்கும் தன்மை Android சாதனத்தைப் பொறுத்தது", + "downloadsDirectoryUnavailable": "இந்தச் சாதனத்தில் Download/Gopeed-இல் Gopeed எழுத முடியாது", + "enterValue": "விளக்கச் சீட்டு உரையை இடு", + "trackers": "& வெற்றிகரமான மாற்றல்", + "oneTrackerPerLine": "ஒரு வரிக்கு ஒரு டிராக்கர் URL", + "pasteDownloadLinks": "HTTP, HTTPS அல்லது காந்த இணைப்புகளை ஒட்டவும் அல்லது டொரண்ட் கோப்பை இங்கே விடவும்", + "unableReadTorrent": "டொரண்ட் கோப்பைப் படிக்க முடியவில்லை: {error}", + "torrentOnly": ".டொரண்ட் கோப்புகள் மட்டுமே ஆதரிக்கப்படுகின்றன", + "torrentDataEmpty": "டொரண்ட் கோப்பு தரவு காலியாக உள்ளது", + "torrentPathUnavailable": "டொரண்ட் கோப்பு பாதையை அணுக முடியவில்லை", + "selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்", + "resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்", + "resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன", + "resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை", + "noFiles": "(% 1 மொத்தம்) Items (Folders, Files), Size", + "createHistory": "வரலாற்றை உருவாக்கு", + "noHistory": "& வரலாற்றில் கண்டுபிடி... Unlimited (number of lines)", + "directDownloadDescription": "தீர்க்கப்படுவதைத் தவிர்த்து, உடனடியாகப் பணிகளை உருவாக்கவும்", + "taskProgress": "பணி முன்னேற்றம்", + "updateAndResume": "புதுப்பித்து மீண்டும் தொடங்குங்கள்", + "httpHeaders": "HTTP தலைப்புகள்", + "piece": "பீஸ் {number}", + "pieceRange": "துண்டுகள் {start}–{end}", + "connectionStatus": "lan: மற்றும் rlan: அமைப்புName", + "connectionCount": "{count} இணைப்புகள்", + "retrying": "மீண்டும் முயற்சிக்கிறது", + "connectionFailed": "இணைப்பு இயலவில்லை", + "connectionFailedRetries": "{count} மறுமுயற்சிகளுக்குப் பிறகு இணைப்பு தோல்வியடைந்தது", + "retryingCount": "மீண்டும் முயற்சிக்கிறது · {count} மறுமுயற்சிகள்", + "pieceStatus": "துண்டு நிலை", + "peers": "பாலியல் துன்புறுத்தல்களைத் தடுப்பதற்கான கொள்கை", + "seeders": "வேகம்", + "leechers": "வேகம்", + "downloadedAmount": "பதிவிறக்கப்பட்டது", + "uploadedAmount": "பதிவேற்றம் மற்றும் பதிவிறக்கம்", + "shareRatio": "பகிர்வு விகிதம்", + "shareDuration": "பகிர்வு காலம்", + "connectedPeers": "{count} இணைக்கப்பட்டுள்ளது", + "active": "நடப்பில் உள்ள", + "completedPieces": "முடிக்கப்பட்ட துண்டுகள்", + "activePeers": "{count} செயலில் உள்ளது", + "incomplete": "முழுமையற்றது", + "address": "முகவரி", + "client": "சார்ந்திருப்பவன்", + "pieces": "துண்டுகள்", + "source": "மூலம்", + "fileRelevance": "கோப்பு தொடர்பு", + "taskDetails": "பணி விவரங்கள்", + "taskDetailsInfoTab": "விபரங்கள்", + "taskDetailsFilesTab": "கோப்புகள்", + "taskDetailsStatsTab": "புள்ளியியல்", + "noStatistics": "புள்ளிவிவரங்கள் எதுவும் கிடைக்கவில்லை", + "noPeers": "இணைப்புகள் இல்லை", + "deleteTasksTitle": "{count} பணிகளை நீக்கவா?", + "keepFilesDescription": "Gopeed இலிருந்து பணி பதிவை மட்டுமே அகற்றவும்", + "readingRuntimeStatus": "இயக்கநேர நிலையைப் படிக்கிறது...", + "apiConfigSaved": "API அமைப்புகள் சேமிக்கப்பட்டன. அவற்றைப் பயன்படுத்த ஆப்பை மறுதொடக்கம் செய்யவும்", + "socketPathRequired": "சாக்கெட் பாதை தேவை", + "serverRequired": "சேவையகம் தேவை", + "portRangeError": "0 மற்றும் 65535 க்கு இடையில் ஒரு போர்ட்டை உள்ளிடவும்", + "portInUseValue": "போர்ட் {port} ஏற்கனவே பயன்பாட்டில் உள்ளது", + "backgroundLocationPermissionRequired": "பேக்ரௌண்ட் பதிவிறக்கங்களுக்கு எப்போதும் இருப்பிட அணுகலை அனுமதிக்கவும்", + "scriptPath": "ஸ்கிரிப்ட் பாதை", + "logDirectoryUnavailable": "பதிவு அடைவு துவக்கப்படவில்லை", + "unableOpenExternalLink": "வெளிப்புற இணைப்பைத் திறக்க முடியவில்லை", + "neverUpdated": "ஒருபோதும் புதுப்பிக்கப்படவில்லை", + "lastUpdated": "கடைசியாக புதுப்பிக்கப்பட்டது: {time}", + "unableLoadSettings": "அமைப்புகளை ஏற்ற முடியவில்லை", + "fieldRequired": "{field} தேவை", + "addGithubMirror": "கிட்ஹப் மிரரைச் சேர்க்கவும்", + "editGithubMirror": "கிட்ஹப் மிரரைத் திருத்தவும்", + "confirmDeleteTitle": "கோப்பு நீக்குதலை உறுதிப்படுத்துக", + "confirmDeleteNamed": "\"{name}\" ஐ நிச்சயமாக நீக்க விரும்புகிறீர்களா?", + "categoryFieldsRequired": "வகைப் பெயர் மற்றும் பாதை தேவை", + "addCategory": "வகையைச் சேர்க்கவும்", + "editCategory": "வகையைத் திருத்துக", + "deleteCategoryTitle": "வகையை நீக்கவா?", + "updateFailed": "புதுப்பித்தல் தோல்வியுற்றது. பின்னர் மீண்டும் முயற்சிக்கவும்", + "newVersionAvailable": "ஒரு புதிய பதிப்பு கிடைக்கிறது", + "releaseNotes": "புதியது என்ன:", + "noReleaseNotes": "வெளியீட்டு குறிப்புகள் எதுவும் கிடைக்கவில்லை", + "preparingDownload": "பதிவிறக்கத்தைத் தயாரிக்கிறது...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed பின்னணி சேவை", + "androidForegroundNotificationTitle": "Gopeed இயங்குகிறது", + "androidForegroundNotificationText": "பதிவிறக்கங்கள் பின்னணியில் தொடரலாம்" +} diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb new file mode 100644 index 00000000..074624b5 --- /dev/null +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "tr", + "label": "Türkçe", + "error": "Hata", + "tip": "İpucu", + "confirm": "Onayla", + "confirmDelete": "Silmek istediğinize emin misiniz?", + "cancel": "İptal", + "on": "Açık", + "off": "Kapalı", + "selectAll": "Tümünü Seç", + "select": "Seç", + "task": "İşlemler", + "downloading": "İndiriliyor", + "downloaded": "İndirildi", + "setting": "Ayarlar", + "donate": "Bağış Yap", + "exit": "Çıkış", + "pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın", + "create": "İşlem Oluştur", + "directDownload": "Direkt İndir", + "advancedOptions": "Gelişmiş Seçenekler", + "followSettings": "Ayarları Takip Et", + "downloadLink": "İndirme Bağlantısı", + "downloadLinkValid": "Lütfen indirme bağlantısını girin", + "downloadLinkHit": "Lütfen her satıra bir tane indirme bağlantısını girin@append", + "downloadLinkHitDesktop": ", veya torrent dosyasını olarak buraya sürükleyin", + "download": "İndir", + "noFileSelected": "Devam etmek için lütfen en az bir dosya seçin", + "noStoragePermission": "Depolama izni gerekli", + "selectFile": "Dosya Seç", + "rename": "Yeniden Adlandır", + "basic": "Temel", + "advanced": "Gelişmiş", + "general": "Genel", + "downloadDir": "İndirme Dizini", + "rememberLastDownloadDir": "Son görev dizinini otomatik kaydet", + "downloadDirValid": "Lütfen indirme dizinini seçin", + "connections": "Bağlantılar", + "useServerCtime": "Dosya oluşturma tarihi için sunucu zamanını kullan", + "maxRunning": "Maksimum Çalışan İşlem", + "defaultDirectDownload": "Varsayılan olarak direkt indirmeyi işaretle", + "autoStartTasks": "Başlangıçta tamamlanmamış işlemleri otomatik başlat", + "autoTorrentEnable": ".torrent dosyalarından otomatik BitTorrent işlemleri oluştur", + "autoTorrentDeleteAfterDownload": "BitTorrent işlemi oluşturulduktan sonra .torrent dosyasını sil", + "autoDeleteMissingFileTasks": "Eksik dosya işlemlerini otomatik sil", + "items": "{count} öğe", + "subscribeTracker": "Tracker Aboneliği", + "subscribeFail": "Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin", + "update": "Güncelle", + "updateDaily": "Günlük güncelle", + "lastUpdate": "Son güncelleme: @time", + "addTracker": "Tracker Ekle", + "addTrackerHit": "Lütfen her satıra bir tane olacak şekilde tracker sunucu adresini girin", + "ui": "Arayüz", + "theme": "Tema", + "themeSystem": "Sistem", + "themeLight": "Açık", + "themeDark": "Koyu", + "locale": "Dil", + "notifyWhenNewVersion": "Güncellemeleri bildir", + "analyticsEnabled": "İstatistikleri Yükle", + "analyticsEnabledDesc": "Uygulamayı geliştirmemize yardımcı olmak için anonim kullanım verilerini paylaşın", + "about": "Hakkında", + "homepage": "Ana Sayfa", + "version": "Sürüm", + "protocol": "Protokol", + "port": "Port", + "apiToken": "API Anahtarı", + "notSet": "AYARLANMADI", + "set": "AYARLA", + "portInUse": "Port [@port] kullanımda, lütfen portu değiştirin", + "effectAfterRestart": "Yeniden başlatmadan sonra etkili olacak", + "developer": "Geliştirici", + "logDirectory": "Günlük Dizini", + "webhook": "Webhook", + "webhookEnable": "Webhook'u Etkinleştir", + "webhookDesc": "İşlemler tamamlandığında veya başarısız olduğunda HTTP POST bildirimleri gönder", + "webhookUrlHint": "Webhook URL'sini girin", + "webhookTest": "Test Et", + "webhookTestSuccess": "Webhook testi başarılı", + "webhookTestFail": "Webhook testi başarısız", + "script": "Betik", + "scriptEnable": "Betiği Etkinleştir", + "scriptDesc": "İndirmeler başarıyla tamamlandığında özel betikleri çalıştır", + "scriptPathHint": "Betik dosya yolunu girin (örn. /path/to/script.sh)", + "urlInvalid": "Lütfen geçerli bir HTTP veya HTTPS URL'si girin", + "required": "Bu alan zorunludur", + "show": "Göster", + "continueAction": "Devam Et", + "pause": "Duraklat", + "startAll": "Tümünü Başlat", + "pauseAll": "Tümünü Duraklat", + "deleteTask": "@count işlemi sil", + "deleteTaskTip": "İndirilen dosyaları sakla", + "delete": "Sil", + "add": "Ekle", + "edit": "Düzenle", + "newVersionTitle": "Yeni sürüm @version keşfedildi", + "newVersionUpdate": "Şimdi Güncelle", + "newVersionLater": "Daha Sonra", + "extensions": "Eklentiler", + "extensionInstallUrl": "Kurulum URL'si", + "extensionInstallSuccess": "Başarıyla kuruldu", + "extensionUpdateSuccess": "Başarıyla güncellendi", + "extensionDelete": "Eklentiyi Sil", + "extensionAlreadyLatest": "Zaten en son sürüm", + "extensionFind": "Eklenti Bul", + "extensionDevelop": "Eklenti Geliştir", + "extensionStoreTitle": "Eklenti Mağazası", + "extensionStoreSection": "Mağaza Eklentileri", + "extensionInstalledSection": "Yüklü Eklentiler", + "extensionInstalledEmpty": "Henüz yüklü eklenti yok", + "extensionStoreEmpty": "Eşleşen eklenti bulunamadı", + "extensionSearchHint": "Ada, başlığa, açıklamaya veya yazara göre ara", + "extensionSortStars": "En Çok Yıldız Alanlar", + "extensionSortInstalls": "En Çok Yüklenenler", + "extensionSortUpdated": "Son Güncellenenler", + "extensionSortToggle": "Sıralama yönünü değiştir", + "extensionLoadMore": "Daha Fazla Yükle", + "extensionNoMore": "Daha fazla eklenti yok", + "extensionInstalled": "Yüklü", + "extensionCanUpdate": "Güncelleme Mevcut", + "extensionInstall": "Yükle", + "extensionLoadLocal": "Yerel Klasörden Yükle", + "extensionFilterAll": "Tümü", + "extensionFilterMarket": "Mağaza", + "extensionFilterInstalled": "Yüklü", + "extensionManualInstall": "Manuel Yükleme", + "extensionInstallTools": "Yükleme Seçenekleri", + "history": "Geçmiş", + "clearHistory": "Geçmişi Temizle", + "noHistoryFound": "Geçmiş Bulunamadı", + "serviceTitle": "İndirme Servisi", + "serviceText": "Çalışıyor", + "network": "Ağ", + "proxy": "Proxy", + "noProxy": "Proxy Yok", + "systemProxy": "Sistem Proxy'si", + "customProxy": "Özel Proxy", + "server": "Sunucu", + "username": "Kullanıcı Adı", + "password": "Şifre", + "thanks": "Teşekkürler", + "thanksDesc": "Gopeed topluluğunu oluşturmaya ve geliştirmeye yardımcı olan herkese teşekkürler!", + "browserExtension": "Tarayıcı Eklentisi", + "launchAtStartup": "Başlangıçta çalıştır", + "runAsMenubarApp": "Menü çubuğu uygulaması olarak çalıştır", + "runAsMenubarAppDesc": "Dock simgesini gizle ve sadece menü çubuğunda çalıştır", + "seedConfig": "Seed Yapılandırması", + "seedKeep": "Manuel olarak durdurulana kadar seed'e devam et", + "seedRatio": "Seed oranı", + "seedTime": "Seed süresi (dakika)", + "setAsDefaultBtClient": "Varsayılan BitTorrent istemcisi olarak ayarla", + "taskDetail": "İşlem Detayı", + "taskName": "İşlem Adı", + "taskUrl": "İşlem URL'si", + "downloadPath": "İndirme Yolu", + "skipVerifyCert": "Sertifika Doğrulamasını Atla", + "archives": "Arşivler", + "autoExtract": "Arşivleri Otomatik Çıkar", + "archivePassword": "Arşiv Şifresi", + "archivePasswordHint": "Şifre korumalı değilse boş bırakın", + "deleteAfterExtract": "Çıkardıktan sonra arşivi sil", + "extracting": "Çıkarılıyor", + "extractDone": "Çıkarma tamamlandı", + "extractError": "Çıkarma başarısız", + "waitingParts": "Parçalar bekleniyor", + "name": "Ad", + "size": "Boyut", + "unknown": "Bilinmeyen", + "fileSelectedCount": "Dosyalar: ", + "fileSelectedSize": "Boyut: ", + "httpHeader": "Üstbilgi (Header)", + "httpHeaderName": "Üstbilgi Adı", + "httpHeaderValue": "Üstbilgi Değeri", + "login": "Giriş Yap", + "username_required": "Lütfen kullanıcı adınızı girin", + "password_required": "Lütfen şifrenizi girin", + "login_success": "Giriş başarılı", + "login_failed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin", + "login_failed_network": "Giriş başarısız, lütfen ağ bağlantınızı kontrol edin", + "insertPlaceholder": "Yer Tutucu Ekle", + "placeholderYear": "Mevcut yıl", + "placeholderMonth": "Mevcut ay (01-12)", + "placeholderDay": "Mevcut gün (01-31)", + "placeholderDate": "Tam tarih (YYYY-MM-DD)", + "example": "örn. @value", + "downloadCategories": "İndirme Kategorileri", + "categoryMusic": "Müzik", + "categoryVideo": "Video", + "categoryDocument": "Belge", + "categoryProgram": "Program", + "categoryName": "Kategori Adı", + "categoryPath": "Kategori Yolu", + "builtInCategory": "Yerleşik kategori silinemez", + "selectCategory": "Kategori Seç", + "quickDirectories": "Hızlı klasörler", + "githubMirror": "GitHub Yansısı", + "githubMirrorEnable": "GitHub Yansısını Etkinleştir", + "githubMirrorDesc": "GitHub içerik indirmelerini hızlandırmak için yansıları kullanın", + "githubMirrorType": "Yansı Türü", + "githubMirrorUrl": "Yansı URL'si", + "githubMirrorUrlHint": "Yansı URL'sini girin", + "updateUrl": "Güncelleme URL'si", + "updateUrlManual": "Manuel Güncelleme", + "updateUrlListen": "Güncellemeyi İzle", + "updateUrlListeningTip": "URL güncellemesi bekleniyor", + "updateUrlCancelListen": "İzlemeyi İptal Et", + "updateUrlDialogHint": "Yeni indirme URL'sini girin", + "pendingUpdateFound": "Bekleyen Güncelleme İşlemi Bulundu", + "pendingUpdateConfirm": "\"{name}\" işlemi URL güncellemesi bekliyor. Yeni URL ile güncellemek istiyor musunuz?", + "pendingUpdateYes": "İşlemi Güncelle", + "pendingUpdateNo": "Yeni İşlem Oluştur", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP Dinle Port", + "ed2kUdpPort": "UDP Dinle Port", + "ed2kServerList": "Sunucu Adresleri", + "ed2kServerMet": "Server.met Kaynakları", + "ed2kNodesDat": "düğümler.dat Sources", + "ed2kAutoPort": "Auto Statement Auto", + "ed2kOnePerLine": "Bir giriş çizgisine", + "ed2kServersHint": "host:port, bir satır başına", + "ed2kServerMetHint": "Server.met URL veya ed2k sunucu listesi bağlantısı, bir satır başına", + "ed2kNodesDatHint": "düğümler.dat yol veya URL, bir satır başına", + "desktopNotification": "Masaüstü Bildirimleri", + "backgroundLocationKeepAlive": "Arka Planda İndirmeyi Sürdür", + "notificationTaskDone": "Görev tamamlandı", + "notificationTaskError": "Görev hatası", + "appTitle": "Gopeed İndirme Yöneticisi", + "unsupportedWindowPayload": "Desteksiz pencere isteği", + "runtimeInitializationFailed": "Gopeed çalışma ortamı başlatılamadı.\n{error}", + "save": "Kaydet", + "retry": "Yeniden Dene", + "close": "Kapat", + "back": "Geri", + "open": "Aç", + "openFile": "Dosyayı Aç", + "share": "Paylaş", + "copy": "Kopyala", + "copied": "Kopyalandı", + "refresh": "Yenile", + "status": "Durum durumu", + "createdAt": "Oluşturulma zamanı", + "speed": "Hız", + "uploadSpeed": "Yük hızı", + "remaining": "Kalanlar", + "downloadDuration": "İndirme süresi", + "progress": "İlerleme", + "failed": "Başarısız", + "completed": "Tamamlandı", + "waiting": "Beklen", + "taskFailed": "Görev başarısız oldu", + "unknownSize": "Bilinmeyen boyut", + "secondsRemaining": "{count} saniye kaldı", + "minutesRemaining": "{count} dakika kaldı", + "hoursRemaining": "{count} saat kaldı", + "secondsDuration": "{count} saniye", + "minutesDuration": "{count} dakika", + "hoursDuration": "{count} saat", + "followSystem": "Sistem Varsayılanı", + "basicSettings": "Temel Ayarlar", + "downloadSettings": "İndirme Ayarları", + "advancedSettings": "Gelişmiş Ayarlar", + "themeColor": "Tema Rengi", + "themeColorDescription": "Uygulama aksan rengini özelleştirme", + "browserExtensionDescription": "Tarayıcı yüklemeleri doğrudan yüklemeden sonra atılır", + "desktopNotificationDescription": "Bir görev tamamlandığında bir sistem bildirimini gösterin veya başarısız olur", + "backgroundLocationKeepAliveDescription": "Arka planda aktif indirmeleri tutmak için yer erişim kullanın", + "currentVersion": "Şimdiki sürüm {version}", + "contributors": "Katkıda bulunanlar", + "viewContributors": "Katkıda bulunanları görüntüle", + "maxRunningDescription": "Aynı zamanda indirilebilecek en fazla görev sayısı", + "defaultUserAgentDescription": "HTTP ve HTTPS indirmeleri için varsayılan User-Agent", + "listenPort": "Dinleme Portu", + "onePerLine": "Bir giriş çizgisine", + "proxyProtocol": "Proxy Protokolü", + "restartRequired": "Bu ayar, uygulamayı yeniden başlatmadan sonra etki alır", + "socketPath": "Use Path", + "listenAddress": "Dinle", + "currentConnection": "Mevcut Bağlantı", + "webhookPush": "Webhook Uç Noktaları", + "webhookPushDescription": "HTTP POST bildirimleri, görevleri tamamlandığında veya başarısız olduğunda bu uç noktalarına gönderin", + "scriptExecution": "Senaryolar", + "scriptExecutionDescription": "Bu senaryoları bir indirme tamamlandıktan sonra çalıştırın", + "addWebhook": "Webhook Ekle", + "editWebhook": "Webhook Düzenle", + "addScript": "Add script", + "editScript": "Edit script", + "openDirectory": "Open Directory", + "browseFiles": "Dosyalara göz at", + "updateEveryDay": "Güncelleme günlük Güncelleme", + "updateVersion": "Güncelle", + "checkForUpdates": "Updates için kontrol", + "updating": "Updating...", + "colorGreen": "Yeşil", + "colorRed": "Kırmızı", + "colorRose": "Gül", + "colorOrange": "Turuncu", + "colorCyan": "Camgöbeği", + "colorBlue": "Mavi", + "colorAmber": "Kehribar", + "colorPurple": "Mor", + "noExtensions": "Uzantı yok", + "searchExtensions": "Arama uzantıları...", + "extensionDeveloperGuide": "Uzantı geliştirici kılavuzu", + "extensionInstallFromUrl": "URL'den yükleme", + "unableOpenExtensionDocs": "uzatma geliştirici kılavuzu açmak için kullanılamaz", + "removeExtensionTitle": "Yükleme uzatma?", + "removeExtensionConfirm": "“{name}” yüklememek istediğinizden emin misiniz?", + "uninstall": "Kaldır", + "unableLoadExtensions": "uzatmaları yükleyemez", + "filters": "Filtreler", + "allTasks": "Tüm Görevler", + "searchDownloads": "Arama indirs...", + "searchHistory": "Geçmişte ara…", + "addTask": "Görev Ekle", + "emptyTaskList": "Bu listedeki görev yok", + "noMatchingTasks": "Bulucu görevleri bulunamadı", + "taskList": "Görev Listesi", + "selectedCount": "{selected} / {total} seçilmiş", + "unableOpenPath": "{path}'ü açın", + "unableLocatePath": "{path}'ü bulmak için kullanılamaz", + "clearSelection": "Clear Selection seçimi", + "deselectTask": "Seçimi kaldır", + "selectTask": "Seç", + "updateManually": "Güncelleme manuel olarak manuel Güncelleme", + "stopListening": "Dinlemeyi Durdur", + "listenForUrl": "URL için dinleyin", + "waitingForReplacementUrl": "Bir yedek URL için bekliyor", + "taskNotFound": "Bulamadı", + "storagePath": "Depolama Path", + "directory": "Rehber", + "mode": "Mod", + "keepOriginalName": "Orijinal adı tut", + "enterCount": "Giriş girin", + "chooseDownloadDirectory": "Select download directory", + "applicationStorage": "Uygulama depolama alanı", + "applicationStorageDescription": "Güvenilir uygulama depolama alanı; uygulama kaldırıldığında dosyalar silinir", + "publicDownloadsDescription": "Uygulama kaldırıldıktan sonra dosyaları korur; kullanılabilirlik Android cihazına bağlıdır", + "downloadsDirectoryUnavailable": "Gopeed bu cihazda Download/Gopeed klasörüne yazamıyor", + "enterValue": "Değer girin", + "trackers": "İzleyiciler", + "oneTrackerPerLine": "Bir izer URL hattı başına", + "pasteDownloadLinks": "Paste HTTP, HTTPS veya MAGNET bağlantıları veya burada bir bük dosyası bırak", + "unableReadTorrent": "Ra dosyasını okumak için kullanılamaz: {error}", + "torrentOnly": "Sadece .torrent dosyaları destekleniyor", + "torrentDataEmpty": "Torrent dosya verileri boş", + "torrentPathUnavailable": "Hata dosya yoluna erişmek için kullanılamaz", + "selectAtLeastOneFile": "En az bir dosya seçin", + "resolvedFiles": "Çözülen Dosyalar", + "resolvedFileCount": "{count} dosyaları çözüldü", + "resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir", + "noFiles": "Hiçbir dosya yok", + "createHistory": "Create History Create History", + "noHistory": "Geçmiş yok", + "directDownloadDescription": "Hemen çözümle ve görevleri oluşturun", + "taskProgress": "Görev ilerlemesi", + "updateAndResume": "Güncelleme ve özgeçmiş", + "httpHeaders": "HTTP Başlıkları", + "piece": "{number}", + "pieceRange": "{start}-{end}", + "connectionStatus": "Bağlantı Durumu", + "connectionCount": "{count} bağlantı bağlantıları", + "retrying": "Yeniden deneme", + "connectionFailed": "Bağlantı başarısız oldu", + "connectionFailedRetries": "{count} denemeden sonra bağlantı başarısız oldu", + "retryingCount": "Yeniden deneniyor · {count}. deneme", + "pieceStatus": "Parça Durumu", + "peers": "Eşler", + "seeders": "Tohumcular", + "leechers": "İndirenler", + "downloadedAmount": "İndirilen", + "uploadedAmount": "Yüklenen", + "shareRatio": "Paylaşım oranı", + "shareDuration": "Paylaşım süresi", + "connectedPeers": "{count} bağlantılı", + "active": "Aktif", + "completedPieces": "Tamamlanmış parçalar", + "activePeers": "{count} aktif", + "incomplete": "Tamamlanmamış", + "address": "Adres", + "client": "İstemci", + "pieces": "Plaklar", + "source": "Kaynak Kaynağı", + "fileRelevance": "Dosya ilgisi", + "taskDetails": "Görev Detayları", + "taskDetailsInfoTab": "Detaylar", + "taskDetailsFilesTab": "Dosyalar", + "taskDetailsStatsTab": "İstatistikler", + "noStatistics": "Mevcut istatistikler mevcut değil", + "noPeers": "bağlantıları yok", + "deleteTasksTitle": "{count} görevi mi?", + "keepFilesDescription": "Sadece Gopeed'den görev kayıtlarını kaldır", + "readingRuntimeStatus": "Okuma runtime statüsü...", + "apiConfigSaved": "API ayarları kurtarıldı. Uygulamayı onları uygulamak için yeniden başlat", + "socketPathRequired": "CPU yolu gereklidir", + "serverRequired": "Server gereklidir", + "portRangeError": "0 ile 65535 arasında bir liman girin", + "portInUseValue": "Port {port} zaten kullanımda", + "backgroundLocationPermissionRequired": "Her zaman yer erişim arka yüklemeler için gerekli izin verilir", + "scriptPath": "Senaryo Path", + "logDirectoryUnavailable": "Log directory ilk olarak yayınlanmadı", + "unableOpenExternalLink": "Dış bağlantıyı açmak için kullanılamaz", + "neverUpdated": "Asla güncellenmedi", + "lastUpdated": "Son Güncelleme: {time}", + "unableLoadSettings": "Ayarlar yüklenemedi", + "fieldRequired": "{field} gerekli", + "addGithubMirror": "GitHub Yansısı Ekle", + "editGithubMirror": "GitHub Yansısını Düzenle", + "confirmDeleteTitle": "Onaylamayı Onaylayın mı?", + "confirmDeleteNamed": "“{name}” silmek istediğinizden emin misiniz?", + "categoryFieldsRequired": "Kategori adı ve yol gereklidir", + "addCategory": "Add Category Add Category", + "editCategory": "Edit Kategori", + "deleteCategoryTitle": "Delete kategorisi?", + "updateFailed": "Güncelleme başarısız oldu. Lütfen daha sonra tekrar deneyin", + "newVersionAvailable": "Yeni bir sürüm mevcut", + "releaseNotes": "Yeni Nedir", + "noReleaseNotes": "Mevcut sürüm notları mevcut değildir", + "preparingDownload": "İndirmeyi hazırlamak...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed arka plan hizmeti", + "androidForegroundNotificationTitle": "Gopeed çalışıyor", + "androidForegroundNotificationText": "İndirmeler arka planda devam edebilir" +} diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb new file mode 100644 index 00000000..6b66ee7c --- /dev/null +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "uk", + "label": "Українська", + "error": "Помилка", + "tip": "Підказка", + "confirm": "Підтвердити", + "confirmDelete": "Ви впевнені, що хочете видалити?", + "cancel": "Скасувати", + "on": "Увімк", + "off": "Вимк", + "selectAll": "Вибрати все", + "select": "Вибрати", + "task": "Завдання", + "downloading": "завантажується", + "downloaded": "завантажено", + "setting": "Налаштування", + "donate": "Підтримати", + "exit": "Вихід", + "pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти", + "create": "Створити завдання", + "directDownload": "Пряме завантаження", + "advancedOptions": "Додаткові параметри", + "followSettings": "За замовчуванням", + "downloadLink": "Посилання на завантаження", + "downloadLinkValid": "Будь ласка, введіть посилання на завантаження", + "downloadLinkHit": "Будь ласка, введіть посилання на завантаження, одне на рядок@Append", + "downloadLinkHitDesktop": ", або перетягніть сюди файл торенту", + "download": "Завантажити", + "noFileSelected": "Будь ласка, виберіть хоча б один файл для продовження", + "noStoragePermission": "Потрібен дозвіл на зберігання", + "selectFile": "Вибрати файл", + "rename": "Перейменувати", + "basic": "Основні", + "advanced": "Розширені", + "general": "Загальні", + "downloadDir": "Каталог завантажень", + "rememberLastDownloadDir": "Автоматично зберігати останню папку завдання", + "downloadDirValid": "Будь ласка, виберіть каталог завантажень", + "connections": "З’єднання", + "useServerCtime": "Використовувати час сервера для створення файлу", + "maxRunning": "Макс. завдань у черзі", + "defaultDirectDownload": "Пряме завантаження за замовчуванням", + "autoStartTasks": "Авто-старт неповні завдання на старті", + "autoTorrentEnable": "Автоматично створювати BT-завдання з .torrent файлів", + "autoTorrentDeleteAfterDownload": "Видалити .torrent файл після створення BT-завдання", + "autoDeleteMissingFileTasks": "Автоматично видаляти завдання з відсутніми файлами", + "items": "{count} елементів", + "subscribeTracker": "Підписатися на трекер", + "subscribeFail": "Не вдалося підписатися, перевірте мережу або спробуйте пізніше", + "update": "Оновити", + "updateDaily": "Оновлювати щодня", + "lastUpdate": "Останнє оновлення: @time", + "addTracker": "Додати трекер", + "addTrackerHit": "Будь ласка, введіть URL-адресу трекера, одну на рядок", + "ui": "Інтерфейс", + "theme": "Тема", + "themeSystem": "Системна", + "themeLight": "Світла", + "themeDark": "Темна", + "locale": "Мова", + "notifyWhenNewVersion": "Повідомляти про оновлення", + "analyticsEnabled": "Аналітика використання", + "analyticsEnabledDesc": "Використовуйте анонімні дані використання, щоб допомогти нам покращити", + "about": "Про програму", + "homepage": "Домашня сторінка", + "version": "Версія", + "protocol": "Протокол", + "port": "Порт", + "apiToken": "API Токен", + "notSet": "Не задано", + "set": "Задати", + "portInUse": "Порт [@PORT] вже використовується, будь ласка, змініть порт", + "effectAfterRestart": "Ефект після перезапуску", + "developer": "Розробник", + "logDirectory": "Каталог журналів", + "webhook": "Веб-сайт", + "webhookEnable": "Увімкнути Webhook", + "webhookDesc": "Відправлення повідомлень HTTP POST при виконанні завдань або не вдається", + "webhookUrlHint": "Веб-сайт", + "webhookTest": "Тестування", + "webhookTestSuccess": "Webhook тест успішний", + "webhookTestFail": "Не вдалося перевірити webhook", + "script": "Списання", + "scriptEnable": "Увімкнути скрипт", + "scriptDesc": "Виконувати користувальницькі сценарії при завантаженні успішно", + "scriptPathHint": "Вкажіть шлях до файлу скрипта (наприклад, /path/to/script.sh)", + "urlInvalid": "Введіть дійсний URL-адресу HTTP або HTTPS", + "required": "Це поле обов'язкове", + "show": "Показати", + "continueAction": "Продовжити", + "pause": "Пауза", + "startAll": "Запустити все", + "pauseAll": "Зупинити все", + "deleteTask": "Видалити @count завдань", + "deleteTaskTip": "Зберегти завантажені файли", + "delete": "Видалити", + "add": "Додати", + "edit": "Редагування", + "newVersionTitle": "Доступна нова версія @Version", + "newVersionUpdate": "Оновити зараз", + "newVersionLater": "Пізніше", + "extensions": "Розширення", + "extensionInstallUrl": "URL для встановлення", + "extensionInstallSuccess": "Встановлено успішно", + "extensionUpdateSuccess": "Оновлено успішно", + "extensionDelete": "Видалити розширення", + "extensionAlreadyLatest": "Вже остання версія", + "extensionFind": "Знайти розширення", + "extensionDevelop": "Розробка розширень", + "extensionStoreTitle": "Магазини", + "extensionStoreSection": "Розширення магазину", + "extensionInstalledSection": "Настроювання", + "extensionInstalledEmpty": "Немає розширень, встановлених ще", + "extensionStoreEmpty": "Не знайдено розширення", + "extensionSearchHint": "Пошук за назвою, заголовком, описом або автором", + "extensionSortStars": "Найпопулярніші зірки", + "extensionSortInstalls": "Найпопулярніші установки", + "extensionSortUpdated": "Останні новини", + "extensionSortToggle": "Попрям на сортування грошових коштів", + "extensionLoadMore": "Навігація", + "extensionNoMore": "Більше про розширення", + "extensionInstalled": "Встановити", + "extensionCanUpdate": "Доступний", + "extensionInstall": "Встановити", + "extensionLoadLocal": "Встановити місцевий склад", + "extensionFilterAll": "Всі", + "extensionFilterMarket": "Торгівля", + "extensionFilterInstalled": "Встановити", + "extensionManualInstall": "Налаштування", + "extensionInstallTools": "Параметри установки", + "history": "Історія", + "clearHistory": "Очистити історію", + "noHistoryFound": "Історію не знайдено", + "serviceTitle": "Служба завантаження", + "serviceText": "Працює", + "network": "Мережа", + "proxy": "Проксі", + "noProxy": "Без проксі", + "systemProxy": "Системний проксі", + "customProxy": "Користувацький проксі", + "server": "Сервер", + "username": "Ім’я користувача", + "password": "Пароль", + "thanks": "Подяки", + "thanksDesc": "Дякуємо всім учасникам, які допомогли створити та розвивати спільноту Gopeed!", + "browserExtension": "Розширення для браузера", + "launchAtStartup": "Запускати під час завантаження системи", + "runAsMenubarApp": "Запускати як додаток у рядку меню", + "runAsMenubarAppDesc": "Приховати значок у Dock і запускати лише в рядку меню", + "seedConfig": "Налаштування роздачі", + "seedKeep": "Роздавати доки не зупиню вручну", + "seedRatio": "Коефіцієнт роздачі", + "seedTime": "Час роздачі (хвилини)", + "setAsDefaultBtClient": "Встановити як клієнт BT за замовчуванням", + "taskDetail": "Деталі завдання", + "taskName": "Назва завдання", + "taskUrl": "URL завдання", + "downloadPath": "Шлях завантаження", + "skipVerifyCert": "Пропустити перевірку сертифіката", + "archives": "Архіви", + "autoExtract": "Автоматично розпаковувати архіви", + "archivePassword": "Пароль архіву", + "archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем", + "deleteAfterExtract": "Видалити архів після розпакування", + "extracting": "Розпакування", + "extractDone": "Розпакування завершено", + "extractError": "Помилка розпакування", + "waitingParts": "Очікування частин", + "name": "Назва", + "size": "Розмір", + "unknown": "Невідомо", + "fileSelectedCount": "Файлів: ", + "fileSelectedSize": "Розмір: ", + "httpHeader": "Помічник", + "httpHeaderName": "Назва заголовка HTTP", + "httpHeaderValue": "Значення заголовка HTTP", + "login": "Логін", + "username_required": "Введіть Ваше ім'я користувача", + "password_required": "Введіть пароль", + "login_success": "Вступна робота", + "login_failed": "Ввійти не вдалося, будь ласка, перевірте ім'я користувача та пароль", + "login_failed_network": "Не вдалося перевірити підключення мережі", + "insertPlaceholder": "Вставити заповнювач", + "placeholderYear": "Поточний рік", + "placeholderMonth": "Поточний місяць (01-12)", + "placeholderDay": "Поточний день (01-31)", + "placeholderDate": "Повна дата (РРРР-ММ-ДД)", + "example": "напр. @value", + "downloadCategories": "Категорії завантажень", + "categoryMusic": "Музика", + "categoryVideo": "Відео", + "categoryDocument": "Документ", + "categoryProgram": "Програма", + "categoryName": "Назва категорії", + "categoryPath": "Шлях категорії", + "builtInCategory": "Вбудовану категорію неможливо видалити", + "selectCategory": "Вибрати категорію", + "quickDirectories": "Швидкі папки", + "githubMirror": "GitHub Дзеркальний", + "githubMirrorEnable": "Увімкнути GitHub Дзеркало", + "githubMirrorDesc": "Використовуйте дзеркал для прискорення завантаження вмісту GitHub", + "githubMirrorType": "Тип дзеркала", + "githubMirrorUrl": "Дзеркальна URL", + "githubMirrorUrlHint": "Вхід на дзеркальну URL", + "updateUrl": "Оновлення URL", + "updateUrlManual": "Оновлення", + "updateUrlListen": "Слухати Оновлення", + "updateUrlListeningTip": "Очікується оновлення URL", + "updateUrlCancelListen": "Скасувати", + "updateUrlDialogHint": "Введіть новий URL-адресу завантаження", + "pendingUpdateFound": "Заява про закінчення оновлення", + "pendingUpdateConfirm": "Завдання \"{name}\" очікує оновлення URL. Ви хочете оновити його з новим URL-адресою?", + "pendingUpdateYes": "Оновлення завдання", + "pendingUpdateNo": "Створити новий завдання", + "ed2k": "ED2K (КМ)", + "ed2kTcpPort": "TCP Слухати Порт", + "ed2kUdpPort": "UDP У прямому ефірі", + "ed2kServerList": "Статус на сервери", + "ed2kServerMet": "Сервер.met Джерела", + "ed2kNodesDat": "nodes.dat Джерела", + "ed2kAutoPort": "Автопристрою", + "ed2kOnePerLine": "Один запис на рядок", + "ed2kServersHint": "электронная пошта: info@hotel.com", + "ed2kServerMetHint": "Сервер.met URL або ed2k посилання на сервер, один за рядок", + "ed2kNodesDatHint": "nodes.dat шлях або URL, один за рядок", + "desktopNotification": "Сповіщення", + "backgroundLocationKeepAlive": "Фонове завантаження", + "notificationTaskDone": "Завдання завершено", + "notificationTaskError": "Помилка завдання", + "appTitle": "Менеджер завантажень Gopeed", + "unsupportedWindowPayload": "Непідтримувані вікна запит", + "runtimeInitializationFailed": "Не вдалося ініціалізувати середовище виконання Gopeed.\n{error}", + "save": "Зареєструватися", + "retry": "птиця", + "close": "КРОК", + "back": "Назад", + "open": "Відкрито", + "openFile": "Відкрити файл", + "share": "Поділитися", + "copy": "Список", + "copied": "Панчохи", + "refresh": "Реверс", + "status": "Контакты", + "createdAt": "Час створення", + "speed": "ОДЯГ", + "uploadSpeed": "Швидкість завантаження", + "remaining": "Ременування", + "downloadDuration": "Тривалість завантаження", + "progress": "Прогрес", + "failed": "Неприємний", + "completed": "Завершений", + "waiting": "Зареєструватися", + "taskFailed": "Завдання не вдалося", + "unknownSize": "Невідомий розмір", + "secondsRemaining": "{count} секунди", + "minutesRemaining": "{count} хвилин, що залишилися", + "hoursRemaining": "{count} годин, що залишилися", + "secondsDuration": "{count} секунд", + "minutesDuration": "{count} хвилин", + "hoursDuration": "{count} годин", + "followSystem": "Системні налаштування", + "basicSettings": "Основні налаштування", + "downloadSettings": "Налаштування завантаження", + "advancedSettings": "Розширені налаштування", + "themeColor": "Тематичні кольори", + "themeColorDescription": "Налаштування кольору акценту додатків", + "browserExtensionDescription": "Після установки натисніть кнопку \"Завантажити\"", + "desktopNotificationDescription": "Показати системне повідомлення при заповненні завдання або не вдається", + "backgroundLocationKeepAliveDescription": "Використовуйте доступ до місця розташування для збереження завантаження активних у фоновому режимі", + "currentVersion": "Поточна версія {version}", + "contributors": "Сприяння", + "viewContributors": "Переглядати вкладників", + "maxRunningDescription": "Максимальна кількість завдань, які можуть завантажити одночасно", + "defaultUserAgentDescription": "За замовчуванням Користувач-Агент для завантаження HTTP і HTTPS", + "listenPort": "Порт прослуховування", + "onePerLine": "Один запис на рядок", + "proxyProtocol": "Протокол проксі", + "restartRequired": "Ця установка приймає ефект після перезавантаження додатку", + "socketPath": "Роз'єм Патріолет", + "listenAddress": "Почати", + "currentConnection": "Поточний підключення", + "webhookPush": "Вебок Endpoints", + "webhookPushDescription": "Відправлення повідомлень HTTP POST на ці кінцеві точки при заповненні завдань або не вдається", + "scriptExecution": "Списки", + "scriptExecutionDescription": "Запустіть ці скрипти після завершення завантаження", + "addWebhook": "Веб-сайт", + "editWebhook": "Редагування Webhook", + "addScript": "Додати скрипт", + "editScript": "Редагувати скрипт", + "openDirectory": "Каталог", + "browseFiles": "Переглянути файли", + "updateEveryDay": "Оновити щоденно", + "updateVersion": "Оновити", + "checkForUpdates": "Перевірити оновлення", + "updating": "Вгору...", + "colorGreen": "Зелений", + "colorRed": "Червоний", + "colorRose": "Рожева", + "colorOrange": "помаранчевий", + "colorCyan": "кицька", + "colorBlue": "Синій", + "colorAmber": "Янтарна", + "colorPurple": "Фіолетовий", + "noExtensions": "Немає розширень", + "searchExtensions": "Розширення для пошуку...", + "extensionDeveloperGuide": "Керівництво розробника", + "extensionInstallFromUrl": "Встановлення розширення з URL", + "unableOpenExtensionDocs": "Неможливо відкрити керівництво розробника", + "removeExtensionTitle": "Розширення для видалення?", + "removeExtensionConfirm": "Ви впевнені, що ви хочете видалити \"{name}\"?", + "uninstall": "Видаляє", + "unableLoadExtensions": "Неможливо до розширення навантаження", + "filters": "Фільтри", + "allTasks": "Всі завдання", + "searchDownloads": "Пошук завантажень...", + "searchHistory": "Пошук в історії…", + "addTask": "Додати завдання", + "emptyTaskList": "Немає завдань в цьому списку", + "noMatchingTasks": "Немає поставлених завдань", + "taskList": "Список завдань", + "selectedCount": "{selected} / {total} вибрано", + "unableOpenPath": "Неможливо відкрити {path}", + "unableLocatePath": "Неможливо знайти {path}", + "clearSelection": "Вибір чіткого", + "deselectTask": "Зняти вибір", + "selectTask": "Вибрати", + "updateManually": "Оновлення вручну", + "stopListening": "Стоп прослуховування", + "listenForUrl": "Слухати URL", + "waitingForReplacementUrl": "Очікується заміною URL", + "taskNotFound": "Не знайдено завдання", + "storagePath": "Шлях зберігання", + "directory": "Довідник", + "mode": "КОМПЛЕКТИ", + "keepOriginalName": "Введіть номер мобільного, який Ви вказали при укладаннi договору з банком - для ідентифікації", + "enterCount": "Вкажіть кількість", + "chooseDownloadDirectory": "Виберіть каталог завантаження", + "applicationStorage": "Сховище застосунку", + "applicationStorageDescription": "Надійне сховище застосунку; файли видаляються після видалення застосунку", + "publicDownloadsDescription": "Зберігає файли після видалення застосунку; доступність залежить від пристрою Android", + "downloadsDirectoryUnavailable": "Gopeed не може записувати в Download/Gopeed на цьому пристрої", + "enterValue": "Ім'я користувача", + "trackers": "Відстеження", + "oneTrackerPerLine": "Один рядок URL-адреси для рядків", + "pasteDownloadLinks": "Завантажуйте файл торрент-файлу, завантажуйте HTTP, HTTPS або MAGNET посилання або опустіть файл торрент-файлу тут", + "unableReadTorrent": "Увімкніть, щоб читати файл торрент-файлу: {error}", + "torrentOnly": "Підтримуються файли torrent", + "torrentDataEmpty": "Порожня інформація про файли, що обертаються", + "torrentPathUnavailable": "Неможливо отримати доступ до торрент-файлу доріжка", + "selectAtLeastOneFile": "Виберіть принаймні один файл", + "resolvedFiles": "Знезалізовані файли", + "resolvedFileCount": "{count} файли вирішені", + "resourceHasNoFiles": "Ресурс не має вибраного списку файлів", + "noFiles": "Немає файлів", + "createHistory": "Історія", + "noHistory": "Історія", + "directDownloadDescription": "Вирішити і створювати завдання відразу", + "taskProgress": "Завдання", + "updateAndResume": "Оновлення та відновлення", + "httpHeaders": "HTTP-головки", + "piece": "Лот {number}", + "pieceRange": "Частини {start}–{end}", + "connectionStatus": "Статус на сервери", + "connectionCount": "{count} з'єднання", + "retrying": "Ретриця", + "connectionFailed": "Не вдалося підключитися", + "connectionFailedRetries": "Не вдалося підключитися після {count} спроб", + "retryingCount": "Повторна спроба · {count}", + "pieceStatus": "Статус на сервери", + "peers": "Пейджери", + "seeders": "Сівалка", + "leechers": "Панчохи", + "downloadedAmount": "Завантажити", + "uploadedAmount": "Завантажено", + "shareRatio": "Коефіцієнт роздачі", + "shareDuration": "Час роздачі", + "connectedPeers": "{count} підключено", + "active": "Активний", + "completedPieces": "Комплекти", + "activePeers": "{count} активний", + "incomplete": "Незакінченна", + "address": "Контакти", + "client": "Клієнт", + "pieces": "Кількість", + "source": "укр", + "fileRelevance": "Релевантність файлів", + "taskDetails": "Деталі завдання", + "taskDetailsInfoTab": "Детальніше", + "taskDetailsFilesTab": "Файли", + "taskDetailsStatsTab": "Статистика", + "noStatistics": "Немає доступних статистичних даних", + "noPeers": "Ні з'єднання", + "deleteTasksTitle": "Видалення задач {count}?", + "keepFilesDescription": "Тільки видаліть запис задачі від Надії", + "readingRuntimeStatus": "Читання статусу робочого часу ...", + "apiConfigSaved": "Налаштування API, збережені. Перезапустіть додаток, щоб застосувати їх", + "socketPathRequired": "План розетки необхідний", + "serverRequired": "Сервер необхідно", + "portRangeError": "Введіть порт між 0 і 65535", + "portInUseValue": "Порт {port} вже в експлуатації", + "backgroundLocationPermissionRequired": "Завжди доступ до місця розташування Allow для завантаження фону", + "scriptPath": "Писана паста", + "logDirectoryUnavailable": "Не ініціалізовано", + "unableOpenExternalLink": "Неможливо відкрити зовнішній посилання", + "neverUpdated": "Не оновлено", + "lastUpdated": "Останнє оновлення: {time}", + "unableLoadSettings": "Неможливо завантажити налаштування", + "fieldRequired": "{field} потрібно", + "addGithubMirror": "Додати GitHub Дзеркало", + "editGithubMirror": "Редагування GitHub Дзеркало", + "confirmDeleteTitle": "Підтвердити видалення?", + "confirmDeleteNamed": "Ви впевнені, що ви хочете видалити \"{name}\"?", + "categoryFieldsRequired": "Категорія ім'я та шлях обов'язкові", + "addCategory": "Додати категорію", + "editCategory": "Редагувати Категорія", + "deleteCategoryTitle": "Категорія Видалити?", + "updateFailed": "Поверніть ще раз", + "newVersionAvailable": "Нова версія доступна", + "releaseNotes": "Що нового", + "noReleaseNotes": "Немає вихідних нот", + "preparingDownload": "Підготовка до завантаження...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Фонова служба Gopeed", + "androidForegroundNotificationTitle": "Gopeed працює", + "androidForegroundNotificationText": "Завантаження можуть продовжуватися у фоновому режимі" +} diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb new file mode 100644 index 00000000..f1d3f6f6 --- /dev/null +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "vi", + "label": "Tiếng Việt", + "error": "Lỗi", + "tip": "Mẹo", + "confirm": "Xác nhận", + "confirmDelete": "Bạn có chắc chắn muốn xóa?", + "cancel": "Hủy", + "on": "Bật", + "off": "Tắt", + "selectAll": "Chọn tất cả", + "select": "Chọn", + "task": "Nhiệm vụ", + "downloading": "đang tải", + "downloaded": "đã tải", + "setting": "Cài đặt", + "donate": "Ủng hộ", + "exit": "Thoát", + "pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát", + "create": "Tạo nhiệm vụ", + "directDownload": "Tải trực tiếp", + "advancedOptions": "Tùy chọn nâng cao", + "followSettings": "Theo thiết lập", + "downloadLink": "Liên kết tải về", + "downloadLinkValid": "Vui lòng nhập liên kết tải về", + "downloadLinkHit": "Vui lòng nhập liên kết tải về, hỗ trợ HTTP/HTTPS/MAGNET@append", + "downloadLinkHitDesktop": ", hoặc kéo tệp torrent vào đây trực tiếp", + "download": "Tải về", + "noFileSelected": "Vui lòng chọn ít nhất một tệp để tiếp tục", + "noStoragePermission": "Yêu cầu quyền lưu trữ", + "selectFile": "Chọn tệp", + "rename": "Đổi tên", + "basic": "Cơ bản", + "advanced": "Nâng cao", + "general": "Chung", + "downloadDir": "Thư mục tải về", + "rememberLastDownloadDir": "Tự động lưu thư mục tác vụ gần nhất", + "downloadDirValid": "Vui lòng chọn thư mục tải về", + "connections": "Kết nối", + "useServerCtime": "Sử dụng thời gian máy chủ cho việc tạo tệp", + "maxRunning": "Số nhiệm vụ tối đa", + "defaultDirectDownload": "Kiểm tra trực tiếp tải về theo mặc định", + "autoStartTasks": "Khởi động tự động các tác vụ không đầy đủ khi khởi chạy", + "autoTorrentEnable": "Tự động tạo nhiệm vụ BT từ file .torrent", + "autoTorrentDeleteAfterDownload": "Xóa file .torrent sau khi tạo nhiệm vụ BT", + "autoDeleteMissingFileTasks": "Tự động xóa tác vụ có tệp bị thiếu", + "items": "{count} mục", + "subscribeTracker": "Theo dõi Tracker", + "subscribeFail": "Theo dõi thất bại, vui lòng kiểm tra kết nối mạng hoặc thử lại sau", + "update": "Cập nhật", + "updateDaily": "Cập nhật hàng ngày", + "lastUpdate": "Cập nhật lần cuối: @time", + "addTracker": "Thêm Tracker", + "addTrackerHit": "Vui lòng nhập địa chỉ máy chủ tracker, mỗi dòng một", + "ui": "Giao diện", + "theme": "Chủ đề", + "themeSystem": "Hệ thống", + "themeLight": "Sáng", + "themeDark": "Tối", + "locale": "Ngôn ngữ", + "notifyWhenNewVersion": "Thông báo cập nhật", + "analyticsEnabled": "Tải thống kê", + "analyticsEnabledDesc": "Chia sẻ dữ liệu sử dụng vô danh để giúp cải thiện", + "about": "Về chúng tôi", + "homepage": "Trang chủ", + "version": "Phiên bản", + "protocol": "Giao thức", + "port": "Cổng", + "apiToken": "Mã API", + "notSet": "Chưa đặt", + "set": "Đặt", + "portInUse": "Cổng [@ info] đang được dùng, xin hãy thay đổi cổng", + "effectAfterRestart": "Hiệu lực sau khi khởi động lại", + "developer": "Nhà phát triển", + "logDirectory": "Thư mục ghi lưu", + "webhook": "móc câu", + "webhookEnable": "Bật móc/ kéo", + "webhookDesc": "Gửi thông báo dạng thực thể HTTP khi nhiệm vụ hoàn tất hay không thành công", + "webhookUrlHint": "Nhập URL của móc câu", + "webhookTest": "Thử", + "webhookTestSuccess": "Kiểm tra webhook thành công", + "webhookTestFail": "Kiểm tra webhook thất bại", + "script": "Văn lệnh", + "scriptEnable": "Bật văn lệnh", + "scriptDesc": "Thực hiện văn lệnh tự chọn khi tải về xong", + "scriptPathHint": "Nhập đường dẫn tập tin tập tin văn lệnh (v. d., / chap/to/script.sh)", + "urlInvalid": "Hãy nhập một địa chỉ URL HTTP hoặc HTTPS hợp lệ", + "required": "Cần thiết khu vực này", + "show": "Hiện", + "continueAction": "Tiếp tục", + "pause": "Tạm dừng", + "startAll": "Bắt đầu tất cả", + "pauseAll": "Tạm dừng tất cả", + "deleteTask": "Xóa @count nhiệm vụ", + "deleteTaskTip": "Giữ các tệp đã tải về", + "delete": "Xóa", + "add": "Thêm", + "edit": "Sửa", + "newVersionTitle": "Khám phá phiên bản mới @version", + "newVersionUpdate": "Cập nhật ngay", + "newVersionLater": "Sau", + "extensions": "Tiện ích mở rộng", + "extensionInstallUrl": "Liên kết cài đặt", + "extensionInstallSuccess": "Cài đặt thành công", + "extensionUpdateSuccess": "Cập nhật thành công", + "extensionDelete": "Xóa tiện ích mở rộng", + "extensionAlreadyLatest": "Đây đã là phiên bản mới nhất", + "extensionFind": "Tìm tiện ích mở rộng", + "extensionDevelop": "Phát triển tiện ích mở rộng", + "extensionStoreTitle": "Lưu mở rộng", + "extensionStoreSection": "Lưu mở rộng", + "extensionInstalledSection": "Phần mở rộng đã cài đặt", + "extensionInstalledEmpty": "Chưa cài đặt phần mở rộng", + "extensionStoreEmpty": "Không tìm thấy phần mở rộng khớp", + "extensionSearchHint": "Tìm kiếm theo tên, tựa, mô tả hoặc tác giả", + "extensionSortStars": "Phần lớn các sao", + "extensionSortInstalls": "Phần lớn các cài đặt", + "extensionSortUpdated": "Cập nhật vừa dùng", + "extensionSortToggle": "Bật/ tắt hướng sắp xếp", + "extensionLoadMore": "Tải thêm", + "extensionNoMore": "Không còn phần mở rộng", + "extensionInstalled": "Đã cài đặt", + "extensionCanUpdate": "Cập nhật sẵn sàng", + "extensionInstall": "Cài đặt", + "extensionLoadLocal": "Cài đặt thư mục cục bộ", + "extensionFilterAll": "Tất cả", + "extensionFilterMarket": "Thị trường", + "extensionFilterInstalled": "Đã cài đặt", + "extensionManualInstall": "Cài đặt Bằng tay", + "extensionInstallTools": "Tùy chọn cài đặt", + "history": "Lịch sử", + "clearHistory": "Xóa lịch sử", + "noHistoryFound": "Không tìm thấy lịch sử", + "serviceTitle": "Dịch vụ Tải xuống", + "serviceText": "Đang chạy", + "network": "Mạng", + "proxy": "Ủy nhiệm", + "noProxy": "Không ủy nhiệm", + "systemProxy": "Ủy nhiệm hệ thống", + "customProxy": "Ủy nhiệm tự chọn", + "server": "Máy chủ", + "username": "Tên đăng nhập", + "password": "Mật khẩu", + "thanks": "Cảm ơn", + "thanksDesc": "Cảm ơn tất cả những người đóng góp đã giúp xây dựng và phát triển cộng đồng Gopeed!", + "browserExtension": "Tiện ích mở rộng trình duyệt", + "launchAtStartup": "Khởi động cùng hệ thống", + "runAsMenubarApp": "Chạy như ứng dụng thanh menu", + "runAsMenubarAppDesc": "Ẩn biểu tượng Dock và chỉ chạy trong thanh menu", + "seedConfig": "Cấu hình hạt giống", + "seedKeep": "Giữ hạt giống cho đến khi tay dừng lại", + "seedRatio": "Tốc độ Hạt giống", + "seedTime": "Thời gian hạt giống ( Phút)", + "setAsDefaultBtClient": "Đặt là trình khách BT mặc định", + "taskDetail": "Chi tiết tác vụ", + "taskName": "Tên tác vụ", + "taskUrl": "URL tác vụ", + "downloadPath": "Đường dẫn tải về", + "skipVerifyCert": "Bỏ qua xác thực chứng chỉ", + "archives": "Tập tin nén", + "autoExtract": "Tự động giải nén tập tin nén", + "archivePassword": "Mật khẩu tập tin nén", + "archivePasswordHint": "Để trống nếu không có mật khẩu", + "deleteAfterExtract": "Xóa tập tin nén sau khi giải nén", + "extracting": "Đang giải nén", + "extractDone": "Giải nén hoàn tất", + "extractError": "Giải nén thất bại", + "waitingParts": "Đang chờ các phần", + "name": "Tên", + "size": "Cỡ", + "unknown": "Không rõ", + "fileSelectedCount": "Tập tin:", + "fileSelectedSize": "Cỡ:", + "httpHeader": "Đầu trang", + "httpHeaderName": "Tên đầu trang", + "httpHeaderValue": "Giá trị Tiêu đề", + "login": "Đăng nhập", + "username_required": "Hãy nhập tên người dùng", + "password_required": "Xin hãy nhập mật khẩu của bạn", + "login_success": "Đăng nhập thành công", + "login_failed": "Lỗi đăng nhập, xin kiểm tra tên người dùng và mật khẩu của bạn", + "login_failed_network": "Lỗi đăng nhập, vui lòng kiểm tra kết nối mạng của bạn", + "insertPlaceholder": "Chèn placeholder", + "placeholderYear": "Năm hiện tại", + "placeholderMonth": "Tháng hiện tại (01-12)", + "placeholderDay": "Ngày hiện tại (01-31)", + "placeholderDate": "Ngày đầy đủ (YYYY-MM-DD)", + "example": "vd. @value", + "downloadCategories": "Danh mục tải xuống", + "categoryMusic": "Nhạc", + "categoryVideo": "Ảnh động", + "categoryDocument": "Tài liệu", + "categoryProgram": "Chương trình", + "categoryName": "Tên danh mục", + "categoryPath": "Đường dẫn danh mục", + "builtInCategory": "Không thể xóa danh mục tích hợp", + "selectCategory": "Chọn danh mục", + "quickDirectories": "Thư mục nhanh", + "githubMirror": "Gương GitHub", + "githubMirrorEnable": "Bật gương GitHub", + "githubMirrorDesc": "Dùng gương để tăng tốc độ tải về nội dung của GitHub", + "githubMirrorType": "Kiểu phản chiếu", + "githubMirrorUrl": "Đối xứng URL", + "githubMirrorUrlHint": "Nhập URL phản chiếu", + "updateUrl": "Cập nhật URL", + "updateUrlManual": "Cập nhật thủ công", + "updateUrlListen": "Nghe cập nhật", + "updateUrlListeningTip": "Đang đợi cập nhật URL", + "updateUrlCancelListen": "Thôi lắng nghe", + "updateUrlDialogHint": "Nhập URL mới", + "pendingUpdateFound": "Đang đợi tác vụ cập nhật", + "pendingUpdateConfirm": "Tác vụ « {name} » đang đợi cập nhật URL. Bạn có muốn cập nhật nó bằng địa chỉ URL mới không?", + "pendingUpdateYes": "Cập nhật tác vụ", + "pendingUpdateNo": "Tạo tác vụ mới", + "ed2k": "ED2K", + "ed2kTcpPort": "Cổng nghe TCP", + "ed2kUdpPort": "Cổng nghe UDP", + "ed2kServerList": "Địa chỉ máy phục vụ", + "ed2kServerMet": "Nguồn máy phục vụ.met", + "ed2kNodesDat": "Nguồn nodes.dat", + "ed2kAutoPort": "Tự động chỉ định", + "ed2kOnePerLine": "Một mục trên mỗi dòng", + "ed2kServersHint": "máy:port, 1 / 1", + "ed2kServerMetHint": "Máy phục vụ.met URL hoặc ed2k cam kết máy chủ, một trên mỗi dòng", + "ed2kNodesDatHint": "đường dẫn.dat hay URL, mỗi dòng một", + "desktopNotification": "Thông báo trên màn hình", + "backgroundLocationKeepAlive": "Tải về nền", + "notificationTaskDone": "Nhiệm vụ hoàn thành", + "notificationTaskError": "Lỗi nhiệm vụ", + "appTitle": "Trình quản lý tải xuống Gopeed", + "unsupportedWindowPayload": "Yêu cầu cửa sổ không được hỗ trợ", + "runtimeInitializationFailed": "Không thể khởi tạo môi trường chạy Gopeed.\n{error}", + "save": "Lưu", + "retry": "Thử lại", + "close": "Đóng", + "back": "Quay lại", + "open": "Mở", + "openFile": "Mở tệp", + "share": "Chia sẻ", + "copy": "Chép", + "copied": "Co dãn", + "refresh": "Cập nhật", + "status": "Trạng thái", + "createdAt": "Thời gian tạo", + "speed": "Tốc độ", + "uploadSpeed": "Tốc độ", + "remaining": "Ở lại", + "downloadDuration": "Thời gian tải xuống", + "progress": "Quá trình", + "failed": "Thất bại", + "completed": "Hoàn tất", + "waiting": "Đang đợi", + "taskFailed": "Tác vụ thất bại", + "unknownSize": "Kích cỡ lạ", + "secondsRemaining": "{count} giây còn lại", + "minutesRemaining": "Còn {count} phút nữa", + "hoursRemaining": "{count} giờ còn lại", + "secondsDuration": "{count} giây", + "minutesDuration": "{count} phút", + "hoursDuration": "{count} giờ", + "followSystem": "Theo hệ thống", + "basicSettings": "Thiết lập cơ bản", + "downloadSettings": "Thiết lập tải về", + "advancedSettings": "Thiết lập cấp cao", + "themeColor": "Màu sắc thái", + "themeColorDescription": "Tùy chỉnh màu sắc giọng nói ứng dụng", + "browserExtensionDescription": "Gửi trực tiếp các tập tin tải về cho trình duyệt sau khi cài đặt", + "desktopNotificationDescription": "Hiển thị thông báo hệ thống khi hoàn thành hay không thành công", + "backgroundLocationKeepAliveDescription": "Dùng quyền truy cập vị trí để giữ cho việc tải về hoạt động ở nền", + "currentVersion": "Phiên bản hiện thời {version}", + "contributors": "Người đóng góp", + "viewContributors": "Đóng góp xem", + "maxRunningDescription": "Số tác vụ tối đa có thể tải về cùng một lúc", + "defaultUserAgentDescription": "User-Agent mặc định cho tải xuống HTTP và HTTPS", + "listenPort": "Cổng lắng nghe", + "onePerLine": "Một mục trên mỗi dòng", + "proxyProtocol": "Giao thức ủy nhiệm", + "restartRequired": "Thiết lập này có hiệu lực sau khi khởi chạy lại ứng dụng", + "socketPath": "Đường dẫn ổ cắm", + "listenAddress": "Địa chỉ lắng nghe", + "currentConnection": "Kết nối", + "webhookPush": "Kết thúc móc", + "webhookPushDescription": "Gửi thông báo PST cho các điểm kết thúc này khi nhiệm vụ hoàn thành hay thất bại", + "scriptExecution": "Văn lệnh", + "scriptExecutionDescription": "Chạy những tập lệnh này sau khi tải về đã thành công", + "addWebhook": "Thêm móc", + "editWebhook": "Sửa đổi móc câu", + "addScript": "Thêm văn lệnh", + "editScript": "Sửa văn lệnh", + "openDirectory": "Thư mục mở", + "browseFiles": "Duyệt tệp", + "updateEveryDay": "Cập nhật mỗi ngày", + "updateVersion": "Cập nhật", + "checkForUpdates": "Kiểm tra cập nhật", + "updating": "Đang cập nhật...", + "colorGreen": "Xanh lá cây", + "colorRed": "Đỏ", + "colorRose": "Hoa hồng", + "colorOrange": "Cam", + "colorCyan": "Xanh lông mòng", + "colorBlue": "Xanh nước biển", + "colorAmber": "Hổ phách", + "colorPurple": "Màu tím", + "noExtensions": "Không có phần mở rộng", + "searchExtensions": "Tìm kiếm phần mở rộng...", + "extensionDeveloperGuide": "Hướng dẫn phát triển mở rộng", + "extensionInstallFromUrl": "Cài đặt phần mở rộng từ URL", + "unableOpenExtensionDocs": "Không thể mở hướng dẫn phát triển phần mở rộng", + "removeExtensionTitle": "Hủy cài đặt phần mở rộng?", + "removeExtensionConfirm": "Bạn có chắc muốn gỡ bỏ cài đặt “{name} chiếc xe không?", + "uninstall": "Hủy cài đặt", + "unableLoadExtensions": "Không thể nạp phần mở rộng", + "filters": "Lọc", + "allTasks": "Mọi tác vụ", + "searchDownloads": "Tải về...", + "searchHistory": "Tìm kiếm lịch sử…", + "addTask": "Thêm tác vụ", + "emptyTaskList": "Không có tác vụ trong danh sách này", + "noMatchingTasks": "Không tìm thấy tác vụ khớp", + "taskList": "Danh sách tác vụ", + "selectedCount": "{selected} / {total} được chọn", + "unableOpenPath": "Không thể mở {path}", + "unableLocatePath": "Không thể xác định vị trí {path}", + "clearSelection": "Xóa vùng chọn", + "deselectTask": "Bỏ chọn", + "selectTask": "Chọn", + "updateManually": "Cập nhật bằng tay", + "stopListening": "Đừng nghe nữa", + "listenForUrl": "Lắng nghe URL", + "waitingForReplacementUrl": "Đang chờ địa chỉ Mạng thay thế", + "taskNotFound": "Không tìm thấy tác vụ", + "storagePath": "Đường dẫn kho lưu trữ", + "directory": "Thư mục", + "mode": "Phương thức", + "keepOriginalName": "Giữ tên gốc", + "enterCount": "Số lượng", + "chooseDownloadDirectory": "Chọn thư mục tải về", + "applicationStorage": "Bộ nhớ ứng dụng", + "applicationStorageDescription": "Bộ nhớ ứng dụng đáng tin cậy; tệp sẽ bị xóa khi gỡ cài đặt ứng dụng", + "publicDownloadsDescription": "Giữ lại tệp sau khi gỡ cài đặt; tính khả dụng phụ thuộc vào thiết bị Android", + "downloadsDirectoryUnavailable": "Gopeed không thể ghi vào Download/Gopeed trên thiết bị này", + "enterValue": "Nhập giá trị", + "trackers": "Truyền:", + "oneTrackerPerLine": "Một địa chỉ URL theo dõi trên mỗi dòng", + "pasteDownloadLinks": "Dán HTTP, HTTPS, hoặc dẫn tập tin kiểu MAGNET, hoặc thả tập tin lũ xuống đây", + "unableReadTorrent": "Không thể đọc tập tin lũ: {error}", + "torrentOnly": "Hỗ trợ chỉ tập tin .torent thôi", + "torrentDataEmpty": "Dữ liệu về tập tin đã rỗng", + "torrentPathUnavailable": "Không thể truy cập đường dẫn tập tin lũ lụt", + "selectAtLeastOneFile": "Chọn ít nhất một tập tin", + "resolvedFiles": "Các tập tin đã giải quyết", + "resolvedFileCount": "{count} tập tin được giải quyết", + "resourceHasNoFiles": "Không thể chọn danh sách tập tin", + "noFiles": "Không có tập tin", + "createHistory": "Tạo ra lịch sử", + "noHistory": "Không có lịch sử", + "directDownloadDescription": "Bỏ qua việc giải quyết và tạo tác vụ ngay lập tức", + "taskProgress": "Tiến trình tác vụ", + "updateAndResume": "Cập nhật và tiếp tục", + "httpHeaders": "Đầu trang HTTP", + "piece": "Mảnh {number}", + "pieceRange": "Mảnh {start}–{end}", + "connectionStatus": "Trạng thái kết nối", + "connectionCount": "{count} kết nối", + "retrying": "Đang thử lại", + "connectionFailed": "Kết nối thất bại", + "connectionFailedRetries": "Kết nối thất bại sau {count} lần thử lại", + "retryingCount": "Đang thử lại · lần {count}", + "pieceStatus": "Trạng thái mảnh", + "peers": "Bạn bè", + "seeders": "Tốc độ", + "leechers": "Người tải", + "downloadedAmount": "Tải về", + "uploadedAmount": "Đã tải lên", + "shareRatio": "Tỷ lệ chia sẻ", + "shareDuration": "Thời gian chia sẻ", + "connectedPeers": "{count} đã kết nối", + "active": "Hoạt động", + "completedPieces": "Mảnh hoàn thành", + "activePeers": "{count} hoạt động", + "incomplete": "Chưa hoàn tất", + "address": "Địa chỉ", + "client": "Ứng dụng khách", + "pieces": "Mảnh", + "source": "Nguồn", + "fileRelevance": "Mức liên quan của tệp", + "taskDetails": "Chi tiết tác vụ", + "taskDetailsInfoTab": "Chi tiết", + "taskDetailsFilesTab": "Tập tin", + "taskDetailsStatsTab": "Thống kê", + "noStatistics": "Không có thống kê sẵn sàng", + "noPeers": "Không có kết nối", + "deleteTasksTitle": "Xóa {count} tác vụ?", + "keepFilesDescription": "Chỉ gỡ bỏ hồ sơ tác vụ từ Gopeed", + "readingRuntimeStatus": "Đang đọc trạng thái thời gian chạy...", + "apiConfigSaved": "Thiết lập i đã lưu. Hãy khởi động lại ứng dụng", + "socketPathRequired": "Cần thiết phải có đường dẫn ổ cắm điện", + "serverRequired": "Cần thiết máy phục vụ", + "portRangeError": "Nhập một cổng từ 0 đến 65535", + "portInUseValue": "Cổng {port} đang được sử dụng", + "backgroundLocationPermissionRequired": "Luôn luôn cho phép truy cập địa điểm cần thiết để tải về nền", + "scriptPath": "Văn lệnh", + "logDirectoryUnavailable": "Chưa khởi chạy thư mục ghi lưu", + "unableOpenExternalLink": "Không thể mở liên kết bên ngoài", + "neverUpdated": "Chưa bao giờ cập nhật", + "lastUpdated": "Cập nhật lần cuối: {time}", + "unableLoadSettings": "Không thể nạp thiết lập", + "fieldRequired": "Yêu cầu {field}", + "addGithubMirror": "Thêm gương GitHub", + "editGithubMirror": "Sửa đổi gương GitHub", + "confirmDeleteTitle": "Xác nhận xóa?", + "confirmDeleteNamed": "Bạn có chắc muốn xóa “{name} lá bài không?", + "categoryFieldsRequired": "Cần phải có tên và đường dẫn riêng", + "addCategory": "Thêm phân loại", + "editCategory": "Sửa phân loại", + "deleteCategoryTitle": "Xoá phân loại", + "updateFailed": "Lỗi cập nhật. Xin hãy thử lại sau", + "newVersionAvailable": "Name", + "releaseNotes": "Có gì mới không?", + "noReleaseNotes": "Không có ghi chú giải thoát", + "preparingDownload": "Đang chuẩn bị tải về...", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Dịch vụ nền Gopeed", + "androidForegroundNotificationTitle": "Gopeed đang chạy", + "androidForegroundNotificationText": "Các lượt tải xuống có thể tiếp tục trong nền" +} diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb new file mode 100644 index 00000000..c95d6f77 --- /dev/null +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "zh", + "label": "简体中文", + "error": "错误", + "tip": "提示", + "confirm": "确认", + "confirmDelete": "确定要删除吗?", + "cancel": "取消", + "on": "开启", + "off": "关闭", + "selectAll": "全选", + "select": "选择", + "task": "任务", + "downloading": "下载中", + "downloaded": "已完成", + "setting": "设置", + "donate": "打赏", + "exit": "退出", + "pressBackAgainToExit": "再按一次返回键退出应用", + "create": "创建任务", + "directDownload": "直接下载", + "advancedOptions": "高级选项", + "followSettings": "跟随设置", + "downloadLink": "下载链接", + "downloadLinkValid": "请输入下载链接", + "downloadLinkHit": "请输入下载链接,支持批量下载,每行一个链接@append", + "downloadLinkHitDesktop": ",也可以直接拖拽种子文件到此处", + "download": "下载", + "noFileSelected": "请至少选择一个文件下载", + "noStoragePermission": "需要开启存储权限", + "selectFile": "选择文件", + "rename": "重命名", + "basic": "基础", + "advanced": "高级", + "general": "通用", + "downloadDir": "下载目录", + "rememberLastDownloadDir": "自动保存上次任务目录", + "downloadDirValid": "请选择下载目录", + "connections": "连接数", + "useServerCtime": "创建文件使用服务器时间", + "maxRunning": "最大下载数", + "defaultDirectDownload": "默认启用直接下载", + "autoStartTasks": "启动时自动开始未完成的任务", + "autoTorrentEnable": "自动从 .torrent 文件创建 BT 任务", + "autoTorrentDeleteAfterDownload": "创建 BT 任务后删除 .torrent 文件", + "autoDeleteMissingFileTasks": "自动删除缺失文件任务", + "items": "{count} 项", + "subscribeTracker": "订阅 Tracker", + "subscribeFail": "订阅失败,请检查网络或稍后重试", + "update": "更新", + "updateDaily": "每天自动更新", + "lastUpdate": "上次更新:@time", + "addTracker": "添加 Tracker", + "addTrackerHit": "请输入 tracker 服务器地址,每行一条", + "ui": "界面", + "theme": "主题", + "themeSystem": "跟随系统", + "themeLight": "明亮主题", + "themeDark": "暗黑主题", + "locale": "语言", + "notifyWhenNewVersion": "有新版本时提醒", + "analyticsEnabled": "使用情况分析", + "analyticsEnabledDesc": "分享匿名使用数据以帮助我们改进", + "about": "关于", + "homepage": "主页", + "version": "版本", + "protocol": "通讯协议", + "port": "端口", + "apiToken": "接口令牌", + "notSet": "未设置", + "set": "已设置", + "portInUse": "端口[@port]已被占用,请更换端口", + "effectAfterRestart": "此配置项将在重启应用后生效", + "developer": "开发者", + "logDirectory": "日志目录", + "webhook": "Webhook 推送", + "webhookEnable": "启用 Webhook", + "webhookDesc": "在任务完成或失败时发送 HTTP POST 通知", + "webhookUrlHint": "请输入 Webhook URL", + "webhookTest": "测试", + "webhookTestSuccess": "Webhook 测试成功", + "webhookTestFail": "Webhook 测试失败", + "script": "脚本执行", + "scriptEnable": "启用脚本", + "scriptDesc": "在下载成功完成时执行自定义脚本", + "scriptPathHint": "请输入脚本文件路径(例如:/path/to/script.sh)", + "urlInvalid": "请输入有效的 HTTP 或 HTTPS 链接", + "required": "此项为必填项", + "show": "显示", + "continueAction": "继续", + "pause": "暂停", + "startAll": "全部开始", + "pauseAll": "全部暂停", + "deleteTask": "删除 @count 个任务", + "deleteTaskTip": "保留已下载的文件", + "delete": "删除", + "add": "添加", + "edit": "编辑", + "newVersionTitle": "发现新版本 @version", + "newVersionUpdate": "立即更新", + "newVersionLater": "稍后再说", + "extensions": "扩展", + "extensionInstallUrl": "安装链接", + "extensionInstallSuccess": "安装成功", + "extensionUpdateSuccess": "更新成功", + "extensionDelete": "删除扩展", + "extensionAlreadyLatest": "已经是最新版本", + "extensionFind": "获取扩展", + "extensionDevelop": "开发扩展", + "extensionStoreTitle": "扩展商店", + "extensionStoreSection": "在线扩展", + "extensionInstalledSection": "已安装扩展", + "extensionInstalledEmpty": "暂未安装扩展", + "extensionStoreEmpty": "没有匹配的扩展", + "extensionSearchHint": "按名称、标题、描述或作者搜索", + "extensionSortStars": "最多 Star", + "extensionSortInstalls": "最多安装", + "extensionSortUpdated": "最近更新", + "extensionSortToggle": "切换排序方向", + "extensionLoadMore": "加载更多", + "extensionNoMore": "已经到底了", + "extensionInstalled": "已安装", + "extensionCanUpdate": "可更新", + "extensionInstall": "安装", + "extensionLoadLocal": "安装本地目录", + "extensionFilterAll": "全部", + "extensionFilterMarket": "市场", + "extensionFilterInstalled": "已安装", + "extensionManualInstall": "手动安装", + "extensionInstallTools": "安装方式", + "history": "历史记录", + "clearHistory": "清空历史记录", + "noHistoryFound": "暂无历史记录", + "serviceTitle": "下载服务", + "serviceText": "运行中", + "network": "网络", + "proxy": "代理", + "noProxy": "不使用代理", + "systemProxy": "系统代理", + "customProxy": "自定义代理", + "server": "服务器", + "username": "用户名", + "password": "密码", + "thanks": "鸣谢", + "thanksDesc": "感谢所有为 Gopeed 社区建设添砖加瓦的贡献者们!", + "browserExtension": "浏览器扩展", + "launchAtStartup": "开机自动运行", + "runAsMenubarApp": "以菜单栏应用运行", + "runAsMenubarAppDesc": "隐藏程序坞图标,仅在菜单栏运行", + "seedConfig": "做种设置", + "seedKeep": "持续做种直到手动停止", + "seedRatio": "做种分享率", + "seedTime": "做种时间(分钟)", + "setAsDefaultBtClient": "设为系统默认 BT 客户端", + "taskDetail": "任务详情", + "taskName": "任务名称", + "taskUrl": "任务链接", + "downloadPath": "下载路径", + "skipVerifyCert": "跳过证书验证", + "archives": "压缩包", + "autoExtract": "自动解压压缩包", + "archivePassword": "压缩包密码", + "archivePasswordHint": "如无密码请留空", + "deleteAfterExtract": "解压后删除压缩包", + "extracting": "正在解压", + "extractDone": "解压完成", + "extractError": "解压失败", + "waitingParts": "等待分卷", + "name": "名称", + "size": "大小", + "unknown": "未知", + "fileSelectedCount": "文件数:", + "fileSelectedSize": "大小:", + "httpHeader": "请求头", + "httpHeaderName": "请求头名称", + "httpHeaderValue": "请求头值", + "login": "登录", + "username_required": "请输入用户名", + "password_required": "请输入密码", + "login_success": "登录成功", + "login_failed": "登录失败,请检查用户名和密码", + "login_failed_network": "登录失败,请检查网络连接", + "insertPlaceholder": "插入占位符", + "placeholderYear": "当前年份", + "placeholderMonth": "当前月份 (01-12)", + "placeholderDay": "当前日期 (01-31)", + "placeholderDate": "完整日期 (年-月-日)", + "example": "例如 @value", + "downloadCategories": "下载目录分类", + "categoryMusic": "音乐", + "categoryVideo": "视频", + "categoryDocument": "文档", + "categoryProgram": "程序", + "categoryName": "分类名称", + "categoryPath": "分类路径", + "builtInCategory": "内置分类不能删除", + "selectCategory": "选择分类", + "quickDirectories": "快捷目录", + "githubMirror": "GitHub 镜像", + "githubMirrorEnable": "启用 GitHub 镜像", + "githubMirrorDesc": "使用镜像站点加速 GitHub 内容下载", + "githubMirrorType": "镜像类型", + "githubMirrorUrl": "镜像地址", + "githubMirrorUrlHint": "请输入镜像地址", + "updateUrl": "更新地址", + "updateUrlManual": "手动更新", + "updateUrlListen": "监听更新", + "updateUrlListeningTip": "正在等待更新地址", + "updateUrlCancelListen": "取消监听", + "updateUrlDialogHint": "请输入新的下载地址", + "pendingUpdateFound": "发现待更新任务", + "pendingUpdateConfirm": "任务 \"{name}\" 正在等待更新地址,是否使用新地址更新该任务?", + "pendingUpdateYes": "更新任务", + "pendingUpdateNo": "创建新任务", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP 监听端口", + "ed2kUdpPort": "UDP 监听端口", + "ed2kServerList": "服务器地址", + "ed2kServerMet": "Server.met 源", + "ed2kNodesDat": "nodes.dat 源", + "ed2kAutoPort": "自动分配", + "ed2kOnePerLine": "每行填写一条", + "ed2kServersHint": "host:port,每行一条", + "ed2kServerMetHint": "请输入 Server.met 地址或 ed2k 服务器列表链接,每行一条", + "ed2kNodesDatHint": "请输入 nodes.dat 本地路径或 URL,每行一条", + "desktopNotification": "桌面通知", + "backgroundLocationKeepAlive": "常驻后台", + "notificationTaskDone": "任务完成", + "notificationTaskError": "任务失败", + "appTitle": "Gopeed 下载管理器", + "unsupportedWindowPayload": "不支持的窗口请求", + "runtimeInitializationFailed": "无法初始化 Gopeed 运行环境。\n{error}", + "save": "保存", + "retry": "重试", + "close": "关闭", + "back": "返回", + "open": "打开", + "openFile": "打开文件", + "share": "分享", + "copy": "复制", + "copied": "已复制", + "refresh": "刷新", + "status": "状态", + "createdAt": "创建时间", + "speed": "速度", + "uploadSpeed": "上传速度", + "remaining": "剩余时间", + "downloadDuration": "下载耗时", + "progress": "进度", + "failed": "失败", + "completed": "已完成", + "waiting": "等待中", + "taskFailed": "任务失败", + "unknownSize": "未知大小", + "secondsRemaining": "剩余 {count} 秒", + "minutesRemaining": "剩余 {count} 分钟", + "hoursRemaining": "剩余 {count} 小时", + "secondsDuration": "{count} 秒", + "minutesDuration": "{count} 分钟", + "hoursDuration": "{count} 小时", + "followSystem": "跟随系统", + "basicSettings": "基础设置", + "downloadSettings": "下载设置", + "advancedSettings": "高级设置", + "themeColor": "主题色", + "themeColorDescription": "自定义应用的主色调", + "browserExtensionDescription": "安装后可将浏览器中的下载直接发送到 Gopeed", + "desktopNotificationDescription": "任务完成或失败时显示系统通知", + "backgroundLocationKeepAliveDescription": "下载任务运行时使用定位权限帮助应用在后台保持活动", + "currentVersion": "当前版本 {version}", + "contributors": "贡献者", + "viewContributors": "查看贡献者", + "maxRunningDescription": "同时允许下载的最大任务数", + "defaultUserAgentDescription": "HTTP 和 HTTPS 下载使用的默认 User-Agent", + "listenPort": "监听端口", + "onePerLine": "每行填写一条", + "proxyProtocol": "代理协议", + "restartRequired": "此配置项将在重启应用后生效", + "socketPath": "Socket 路径", + "listenAddress": "监听地址", + "currentConnection": "当前连接", + "webhookPush": "Webhook 地址", + "webhookPushDescription": "任务完成或失败时向这些地址发送 HTTP POST 通知", + "scriptExecution": "脚本", + "scriptExecutionDescription": "下载成功完成后执行这些脚本", + "addWebhook": "添加 Webhook", + "editWebhook": "编辑 Webhook", + "addScript": "添加脚本", + "editScript": "编辑脚本", + "openDirectory": "打开目录", + "browseFiles": "浏览文件", + "updateEveryDay": "每天自动更新", + "updateVersion": "更新版本", + "checkForUpdates": "检查更新", + "updating": "正在更新…", + "colorGreen": "绿色", + "colorRed": "红色", + "colorRose": "玫红", + "colorOrange": "橙色", + "colorCyan": "青色", + "colorBlue": "蓝色", + "colorAmber": "黄色", + "colorPurple": "紫色", + "noExtensions": "暂无扩展", + "searchExtensions": "搜索扩展…", + "extensionDeveloperGuide": "扩展开发指南", + "extensionInstallFromUrl": "通过 URL 安装扩展", + "unableOpenExtensionDocs": "无法打开扩展开发文档", + "removeExtensionTitle": "卸载扩展?", + "removeExtensionConfirm": "确定要卸载“{name}”吗?", + "uninstall": "卸载", + "unableLoadExtensions": "无法加载扩展", + "filters": "筛选", + "allTasks": "全部任务", + "searchDownloads": "搜索下载任务…", + "searchHistory": "搜索历史记录…", + "addTask": "添加任务", + "emptyTaskList": "当前列表暂无任务", + "noMatchingTasks": "未找到相关任务", + "taskList": "任务列表", + "selectedCount": "已选择 {selected} / {total}", + "unableOpenPath": "无法打开 {path}", + "unableLocatePath": "无法找到 {path}", + "clearSelection": "清除选择", + "deselectTask": "取消选择", + "selectTask": "选择", + "updateManually": "手动更新", + "stopListening": "停止监听", + "listenForUrl": "监听 URL", + "waitingForReplacementUrl": "正在等待替换 URL", + "taskNotFound": "任务不存在", + "storagePath": "存储路径", + "directory": "目录", + "mode": "模式", + "keepOriginalName": "保留原文件名", + "enterCount": "请输入数量", + "chooseDownloadDirectory": "选择下载目录", + "applicationStorage": "应用存储", + "applicationStorageDescription": "稳定可用;卸载应用时其中的文件会被删除", + "publicDownloadsDescription": "卸载后仍保留文件;是否可用取决于 Android 设备", + "downloadsDirectoryUnavailable": "当前设备不允许 Gopeed 写入 Download/Gopeed", + "enterValue": "请输入内容", + "trackers": "Tracker", + "oneTrackerPerLine": "每行一个 Tracker URL", + "pasteDownloadLinks": "粘贴 HTTP、HTTPS 或 MAGNET 链接,也可将种子文件拖到这里", + "unableReadTorrent": "无法读取种子文件:{error}", + "torrentOnly": "仅支持 .torrent 文件", + "torrentDataEmpty": "种子文件内容为空", + "torrentPathUnavailable": "无法访问种子文件路径", + "selectAtLeastOneFile": "请至少选择一个文件", + "resolvedFiles": "已解析文件", + "resolvedFileCount": "已解析 {count} 个文件", + "resourceHasNoFiles": "该资源没有可选择的文件列表", + "noFiles": "没有文件", + "createHistory": "创建历史", + "noHistory": "暂无历史记录", + "directDownloadDescription": "跳过解析并立即创建任务", + "taskProgress": "任务进度", + "updateAndResume": "更新并继续", + "httpHeaders": "HTTP 请求头", + "piece": "分片 {number}", + "pieceRange": "分片 {start}–{end}", + "connectionStatus": "连接状态", + "connectionCount": "{count} 个连接", + "retrying": "重试中", + "connectionFailed": "连接失败", + "connectionFailedRetries": "连接失败,已重试 {count} 次", + "retryingCount": "正在重试,已重试 {count} 次", + "pieceStatus": "分片状态", + "peers": "对等节点", + "seeders": "Seeder", + "leechers": "Leecher", + "downloadedAmount": "下载量", + "uploadedAmount": "上传量", + "shareRatio": "分享率", + "shareDuration": "分享时长", + "connectedPeers": "{count} 个已连接", + "active": "活跃", + "completedPieces": "已完成分片", + "activePeers": "{count} 个活跃", + "incomplete": "未完成", + "address": "地址", + "client": "客户端", + "pieces": "分片", + "source": "来源", + "fileRelevance": "文件关联", + "taskDetails": "任务详情", + "taskDetailsInfoTab": "详情", + "taskDetailsFilesTab": "文件", + "taskDetailsStatsTab": "统计", + "noStatistics": "暂无统计数据", + "noPeers": "暂无连接", + "deleteTasksTitle": "删除 {count} 个任务?", + "keepFilesDescription": "仅从 Gopeed 中移除任务记录", + "readingRuntimeStatus": "正在读取运行状态…", + "apiConfigSaved": "API 配置已保存,重启应用后生效", + "socketPathRequired": "Socket 路径不能为空", + "serverRequired": "服务器不能为空", + "portRangeError": "请输入 0–65535 之间的端口", + "portInUseValue": "端口 {port} 已被占用", + "backgroundLocationPermissionRequired": "需要“始终允许”定位权限才能启用后台保活", + "scriptPath": "脚本路径", + "logDirectoryUnavailable": "日志目录尚未初始化", + "unableOpenExternalLink": "无法打开外部链接", + "neverUpdated": "尚未更新", + "lastUpdated": "上次更新:{time}", + "unableLoadSettings": "无法加载设置", + "fieldRequired": "{field}不能为空", + "addGithubMirror": "添加 GitHub 镜像", + "editGithubMirror": "编辑 GitHub 镜像", + "confirmDeleteTitle": "确认删除?", + "confirmDeleteNamed": "确定删除“{name}”吗?", + "categoryFieldsRequired": "分类名称和路径不能为空", + "addCategory": "添加分类", + "editCategory": "编辑分类", + "deleteCategoryTitle": "删除分类?", + "updateFailed": "更新失败,请稍后重试", + "newVersionAvailable": "发现新版本", + "releaseNotes": "更新内容", + "noReleaseNotes": "暂无更新说明", + "preparingDownload": "正在准备下载…", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed 后台服务", + "androidForegroundNotificationTitle": "Gopeed 正在运行", + "androidForegroundNotificationText": "下载可在后台继续" +} diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb new file mode 100644 index 00000000..0d02cca4 --- /dev/null +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "zh_TW", + "label": "繁體中文", + "error": "錯誤", + "tip": "提示", + "confirm": "確認", + "confirmDelete": "確定要刪除嗎?", + "cancel": "取消", + "on": "開啟", + "off": "關閉", + "selectAll": "全選", + "select": "選擇", + "task": "任務", + "downloading": "下載中", + "downloaded": "已下載", + "setting": "設定", + "donate": "捐款", + "exit": "退出", + "pressBackAgainToExit": "再按一次返回鍵退出應用程式", + "create": "建立任務", + "directDownload": "直接下載", + "advancedOptions": "進階選項", + "followSettings": "跟隨設定", + "downloadLink": "下載連結", + "downloadLinkValid": "請輸入下載連結", + "downloadLinkHit": "請輸入下載連結,支援批量下載,每行一個連結@append", + "downloadLinkHitDesktop": ",或直接拖曳種子檔到此處", + "download": "下載", + "noFileSelected": "請至少選擇一個檔案以繼續", + "noStoragePermission": "需要儲存空間權限", + "selectFile": "選擇檔案", + "rename": "重新命名", + "basic": "基本", + "advanced": "進階", + "general": "一般", + "downloadDir": "下載目錄", + "rememberLastDownloadDir": "自動儲存上次任務目錄", + "downloadDirValid": "請選擇下載目錄", + "connections": "連接數", + "useServerCtime": "使用伺服器時間作為檔案建立時間", + "maxRunning": "最大執行任務數", + "defaultDirectDownload": "預設勾選直接下載", + "autoStartTasks": "啟動時自動開始未完成的任務", + "autoTorrentEnable": "自動從 .torrent 檔案建立 BT 任務", + "autoTorrentDeleteAfterDownload": "建立 BT 任務後刪除 .torrent 檔案", + "autoDeleteMissingFileTasks": "自動刪除缺失檔案任務", + "items": "{count} 個項目", + "subscribeTracker": "訂閱 Tracker", + "subscribeFail": "訂閱失敗,請檢查網路或稍後再試", + "update": "更新", + "updateDaily": "每日更新", + "lastUpdate": "最後更新時間:@time", + "addTracker": "新增 Tracker", + "addTrackerHit": "請輸入 Tracker 伺服器 URL,每行一個", + "ui": "介面", + "theme": "主題", + "themeSystem": "系統", + "themeLight": "淺色", + "themeDark": "深色", + "locale": "語言", + "notifyWhenNewVersion": "有新版本時通知", + "analyticsEnabled": "上傳使用統計資料", + "analyticsEnabledDesc": "分享匿名使用資料,協助我們改善 Gopeed", + "about": "關於", + "homepage": "首頁", + "version": "版本", + "protocol": "協議", + "port": "連接埠", + "apiToken": "API 權杖", + "notSet": "未設定", + "set": "已設定", + "portInUse": "連接埠 [@port] 已被使用,請更改連接埠", + "effectAfterRestart": "重新啟動後生效", + "developer": "開發者", + "logDirectory": "日誌目錄", + "webhook": "Webhook", + "webhookEnable": "啟用 Webhook", + "webhookDesc": "任務完成或失敗時傳送 HTTP POST 通知", + "webhookUrlHint": "輸入 Webhook URL", + "webhookTest": "測試", + "webhookTestSuccess": "Webhook 測試成功", + "webhookTestFail": "Webhook 測試失敗", + "script": "指令碼", + "scriptEnable": "啟用指令碼", + "scriptDesc": "下載成功時執行自訂指令碼", + "scriptPathHint": "輸入指令碼檔案路徑(例如 /path/to/script.sh)", + "urlInvalid": "請輸入有效的 HTTP 或 HTTPS URL", + "required": "此欄位為必填", + "show": "顯示", + "continueAction": "繼續", + "pause": "暫停", + "startAll": "全部開始", + "pauseAll": "全部暫停", + "deleteTask": "刪除 @count 個任務", + "deleteTaskTip": "保留已下載的檔案", + "delete": "刪除", + "add": "新增", + "edit": "編輯", + "newVersionTitle": "發現新版本 @version", + "newVersionUpdate": "立即更新", + "newVersionLater": "稍後", + "extensions": "擴充功能", + "extensionInstallUrl": "安裝 URL", + "extensionInstallSuccess": "安裝成功", + "extensionUpdateSuccess": "更新成功", + "extensionDelete": "刪除擴充功能", + "extensionAlreadyLatest": "已是最新版本", + "extensionFind": "尋找擴充功能", + "extensionDevelop": "開發擴充功能", + "extensionStoreTitle": "擴充功能商店", + "extensionStoreSection": "商店中的擴充功能", + "extensionInstalledSection": "已安裝的擴充功能", + "extensionInstalledEmpty": "尚未安裝任何擴充功能", + "extensionStoreEmpty": "找不到符合條件的擴充功能", + "extensionSearchHint": "依名稱、標題、說明或作者搜尋", + "extensionSortStars": "星號數最多", + "extensionSortInstalls": "安裝次數最多", + "extensionSortUpdated": "最近更新", + "extensionSortToggle": "切換排序方向", + "extensionLoadMore": "載入更多", + "extensionNoMore": "沒有更多擴充功能", + "extensionInstalled": "已安裝", + "extensionCanUpdate": "有可用更新", + "extensionInstall": "安裝", + "extensionLoadLocal": "安裝本機資料夾", + "extensionFilterAll": "全部", + "extensionFilterMarket": "商店", + "extensionFilterInstalled": "已安裝", + "extensionManualInstall": "手動安裝", + "extensionInstallTools": "安裝選項", + "history": "歷史記錄", + "clearHistory": "清除歷史記錄", + "noHistoryFound": "未找到歷史記錄", + "serviceTitle": "下載服務", + "serviceText": "運行中", + "network": "網路", + "proxy": "代理", + "noProxy": "沒有 Proxy", + "systemProxy": "系統 Proxy", + "customProxy": "自訂 Proxy", + "server": "伺服器", + "username": "使用者名稱", + "password": "密碼", + "thanks": "感謝", + "thanksDesc": "感謝所有幫助建立和發展 Gopeed 社群的貢獻者!", + "browserExtension": "瀏覽器擴充功能", + "launchAtStartup": "開機自動執行", + "runAsMenubarApp": "以選單列應用程式執行", + "runAsMenubarAppDesc": "隱藏 Dock 圖示,僅在選單列執行", + "seedConfig": "做種設定", + "seedKeep": "持續做種直到手動停止", + "seedRatio": "做種分享率", + "seedTime": "做種時間(分鐘)", + "setAsDefaultBtClient": "設為系統預設 BT 客戶端", + "taskDetail": "任務詳情", + "taskName": "任務名稱", + "taskUrl": "任務連結", + "downloadPath": "下載路徑", + "skipVerifyCert": "跳過憑證驗證", + "archives": "壓縮檔", + "autoExtract": "自動解壓縮壓縮包", + "archivePassword": "壓縮包密碼", + "archivePasswordHint": "如無密碼請留空", + "deleteAfterExtract": "解壓後刪除壓縮檔", + "extracting": "正在解壓", + "extractDone": "解壓完成", + "extractError": "解壓失敗", + "waitingParts": "等待分卷", + "name": "名稱", + "size": "大小", + "unknown": "未知", + "fileSelectedCount": "文件數:", + "fileSelectedSize": "大小:", + "httpHeader": "標頭", + "httpHeaderName": "標頭名稱", + "httpHeaderValue": "標頭值", + "login": "登入", + "username_required": "請輸入使用者名稱", + "password_required": "請輸入密碼", + "login_success": "登入成功", + "login_failed": "登入失敗,請檢查使用者名稱與密碼", + "login_failed_network": "登入失敗,請檢查網路連線", + "insertPlaceholder": "插入佔位符", + "placeholderYear": "當前年份", + "placeholderMonth": "當前月份 (01-12)", + "placeholderDay": "當前日期 (01-31)", + "placeholderDate": "完整日期 (年-月-日)", + "example": "例如 @value", + "downloadCategories": "下載目錄分類", + "categoryMusic": "音樂", + "categoryVideo": "影片", + "categoryDocument": "文件", + "categoryProgram": "程式", + "categoryName": "分類名稱", + "categoryPath": "分類路徑", + "builtInCategory": "內建分類無法刪除", + "selectCategory": "選擇分類", + "quickDirectories": "快捷目錄", + "githubMirror": "GitHub 鏡像", + "githubMirrorEnable": "啟用 GitHub 鏡像", + "githubMirrorDesc": "使用鏡像站加速 GitHub 內容下載", + "githubMirrorType": "鏡像類型", + "githubMirrorUrl": "鏡像 URL", + "githubMirrorUrlHint": "輸入鏡像 URL", + "updateUrl": "更新 URL", + "updateUrlManual": "手動更新", + "updateUrlListen": "監聽更新", + "updateUrlListeningTip": "等待 URL 更新", + "updateUrlCancelListen": "取消監聽", + "updateUrlDialogHint": "輸入新的下載 URL", + "pendingUpdateFound": "發現待更新的任務", + "pendingUpdateConfirm": "任務「{name}」正在等待 URL 更新。要使用新的 URL 更新此任務嗎?", + "pendingUpdateYes": "更新任務", + "pendingUpdateNo": "建立新任務", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP 監聽連接埠", + "ed2kUdpPort": "UDP 監聽連接埠", + "ed2kServerList": "伺服器位址", + "ed2kServerMet": "Server.met 來源", + "ed2kNodesDat": "nodes.dat 來源", + "ed2kAutoPort": "自動分配", + "ed2kOnePerLine": "每行填寫一條", + "ed2kServersHint": "host:port,每行一條", + "ed2kServerMetHint": "請輸入 Server.met 位址或 ed2k 伺服器清單連結,每行一條", + "ed2kNodesDatHint": "請輸入 nodes.dat 本機路徑或 URL,每行一條", + "desktopNotification": "桌面通知", + "backgroundLocationKeepAlive": "背景定位保活", + "notificationTaskDone": "任務完成", + "notificationTaskError": "任務失敗", + "appTitle": "Gopeed 下載管理器", + "unsupportedWindowPayload": "不支援的視窗請求", + "runtimeInitializationFailed": "無法初始化 Gopeed 執行環境。\n{error}", + "save": "儲存", + "retry": "重試", + "close": "關閉", + "back": "返回", + "open": "開啟", + "openFile": "開啟檔案", + "share": "分享", + "copy": "複製", + "copied": "已複製", + "refresh": "重新整理", + "status": "狀態", + "createdAt": "建立時間", + "speed": "速度", + "uploadSpeed": "上傳速度", + "remaining": "剩餘時間", + "downloadDuration": "下載耗時", + "progress": "進度", + "failed": "失敗", + "completed": "已完成", + "waiting": "等待中", + "taskFailed": "任務失敗", + "unknownSize": "未知大小", + "secondsRemaining": "剩餘 {count} 秒", + "minutesRemaining": "剩餘 {count} 分鐘", + "hoursRemaining": "剩餘 {count} 小時", + "secondsDuration": "{count} 秒", + "minutesDuration": "{count} 分鐘", + "hoursDuration": "{count} 小時", + "followSystem": "跟隨系統", + "basicSettings": "基本設定", + "downloadSettings": "下載設定", + "advancedSettings": "進階設定", + "themeColor": "主題色", + "themeColorDescription": "自訂應用程式的主色調", + "browserExtensionDescription": "安裝後可將瀏覽器中的下載直接傳送到 Gopeed", + "desktopNotificationDescription": "任務完成或失敗時顯示系統通知", + "backgroundLocationKeepAliveDescription": "下載任務執行時使用定位許可權幫助應用在後臺保持活動", + "currentVersion": "目前版本 {version}", + "contributors": "感謝貢獻者", + "viewContributors": "檢視貢獻者", + "maxRunningDescription": "同時允許下載的最大任務數", + "defaultUserAgentDescription": "HTTP 和 HTTPS 下載使用的預設 User-Agent", + "listenPort": "監聽連接埠", + "onePerLine": "每行填寫一筆", + "proxyProtocol": "代理協議", + "restartRequired": "此設定將在重新啟動應用程式後生效", + "socketPath": "Socket 路徑", + "listenAddress": "監聽地址", + "currentConnection": "目前連線", + "webhookPush": "Webhook 推送", + "webhookPushDescription": "任務完成或失敗時向這些地址傳送 HTTP POST 通知", + "scriptExecution": "指令碼執行", + "scriptExecutionDescription": "下載成功完成後執行這些指令碼", + "addWebhook": "新增 Webhook", + "editWebhook": "編輯 Webhook", + "addScript": "新增指令碼", + "editScript": "編輯指令碼", + "openDirectory": "開啟目錄", + "browseFiles": "瀏覽檔案", + "updateEveryDay": "每天自動更新", + "updateVersion": "更新版本", + "checkForUpdates": "檢查更新", + "updating": "正在更新…", + "colorGreen": "綠色", + "colorRed": "紅色", + "colorRose": "玫紅", + "colorOrange": "橙色", + "colorCyan": "青色", + "colorBlue": "藍色", + "colorAmber": "黃色", + "colorPurple": "紫色", + "noExtensions": "暫無擴充功能", + "searchExtensions": "搜尋擴充功能…", + "extensionDeveloperGuide": "擴充功能開發指南", + "extensionInstallFromUrl": "透過 URL 安裝擴充功能", + "unableOpenExtensionDocs": "無法開啟擴充套件開發文件", + "removeExtensionTitle": "解除安裝擴充功能?", + "removeExtensionConfirm": "確定要解除安裝「{name}」嗎?", + "uninstall": "解除安裝", + "unableLoadExtensions": "無法載入擴充套件", + "filters": "篩選", + "allTasks": "所有任務", + "searchDownloads": "搜尋下載任務…", + "searchHistory": "搜尋歷史記錄…", + "addTask": "新增任務", + "emptyTaskList": "目前列表沒有任務", + "noMatchingTasks": "找不到符合的任務", + "taskList": "任務列表", + "selectedCount": "已選擇 {selected} / {total}", + "unableOpenPath": "無法開啟 {path}", + "unableLocatePath": "無法找到 {path}", + "clearSelection": "清除選擇", + "deselectTask": "取消選擇", + "selectTask": "選擇", + "updateManually": "手動更新", + "stopListening": "停止監聽", + "listenForUrl": "監聽 URL", + "waitingForReplacementUrl": "正在等待替換 URL", + "taskNotFound": "找不到任務", + "storagePath": "儲存路徑", + "directory": "目錄", + "mode": "模式", + "keepOriginalName": "保留原檔名", + "enterCount": "請輸入數量", + "chooseDownloadDirectory": "選擇下載目錄", + "applicationStorage": "應用程式儲存空間", + "applicationStorageDescription": "穩定可用;解除安裝應用程式時其中的檔案會被刪除", + "publicDownloadsDescription": "解除安裝後仍保留檔案;是否可用取決於 Android 裝置", + "downloadsDirectoryUnavailable": "目前裝置不允許 Gopeed 寫入 Download/Gopeed", + "enterValue": "請輸入內容", + "trackers": "Tracker", + "oneTrackerPerLine": "每行一個 Tracker URL", + "pasteDownloadLinks": "貼上 HTTP、HTTPS 或 MAGNET 連結,也可將種子檔案拖到這裡", + "unableReadTorrent": "無法讀取種子檔案:{error}", + "torrentOnly": "僅支援 .torrent 檔案", + "torrentDataEmpty": "種子檔案內容為空", + "torrentPathUnavailable": "無法訪問種子檔案路徑", + "selectAtLeastOneFile": "請至少選擇一個檔案", + "resolvedFiles": "已解析檔案", + "resolvedFileCount": "已解析 {count} 個檔案", + "resourceHasNoFiles": "該資源沒有可選擇的檔案列表", + "noFiles": "沒有檔案", + "createHistory": "建立歷史記錄", + "noHistory": "暫無歷史記錄", + "directDownloadDescription": "跳過解析並立即建立任務", + "taskProgress": "任務進度", + "updateAndResume": "更新並繼續", + "httpHeaders": "HTTP 請求頭", + "piece": "分片 {number}", + "pieceRange": "分片 {start}–{end}", + "connectionStatus": "連線狀態", + "connectionCount": "{count} 個連線", + "retrying": "重試中", + "connectionFailed": "連線失敗", + "connectionFailedRetries": "連線失敗,已重試 {count} 次", + "retryingCount": "正在重試,已重試 {count} 次", + "pieceStatus": "分片狀態", + "peers": "對等節點", + "seeders": "Seeder", + "leechers": "Leecher", + "downloadedAmount": "下載量", + "uploadedAmount": "上傳量", + "shareRatio": "分享率", + "shareDuration": "分享時長", + "connectedPeers": "{count} 個已連線", + "active": "活躍", + "completedPieces": "已完成分片", + "activePeers": "{count} 個活躍", + "incomplete": "未完成", + "address": "位址", + "client": "用戶端", + "pieces": "分片", + "source": "來源", + "fileRelevance": "檔案關聯", + "taskDetails": "任務詳情", + "taskDetailsInfoTab": "詳情", + "taskDetailsFilesTab": "檔案", + "taskDetailsStatsTab": "統計", + "noStatistics": "暫無統計資料", + "noPeers": "暫無連線", + "deleteTasksTitle": "刪除 {count} 個任務?", + "keepFilesDescription": "僅從 Gopeed 中移除任務記錄", + "readingRuntimeStatus": "正在讀取執行狀態…", + "apiConfigSaved": "API 配置已儲存,重啟應用後生效", + "socketPathRequired": "Socket 路徑不能為空", + "serverRequired": "伺服器不能為空", + "portRangeError": "請輸入 0–65535 之間的埠", + "portInUseValue": "埠 {port} 已被佔用", + "backgroundLocationPermissionRequired": "需要“始終允許”定位許可權才能啟用後臺保活", + "scriptPath": "指令碼路徑", + "logDirectoryUnavailable": "日誌目錄尚未初始化", + "unableOpenExternalLink": "無法開啟外部連結", + "neverUpdated": "尚未更新", + "lastUpdated": "上次更新:{time}", + "unableLoadSettings": "無法載入設定", + "fieldRequired": "{field}不能為空", + "addGithubMirror": "新增 GitHub 映象", + "editGithubMirror": "編輯 GitHub 映象", + "confirmDeleteTitle": "確認刪除?", + "confirmDeleteNamed": "確定刪除“{name}”嗎?", + "categoryFieldsRequired": "分類名稱和路徑不能為空", + "addCategory": "新增分類", + "editCategory": "編輯分類", + "deleteCategoryTitle": "刪除分類?", + "updateFailed": "更新失敗,請稍後重試", + "newVersionAvailable": "發現新版本", + "releaseNotes": "更新內容", + "noReleaseNotes": "暫無更新說明", + "preparingDownload": "正在準備下載…", + "booleanValueHint": "true / false", + "androidForegroundServiceChannel": "Gopeed 背景服務", + "androidForegroundNotificationTitle": "Gopeed 正在執行", + "androidForegroundNotificationText": "下載可在背景繼續" +} diff --git a/ui/flutter/lib/l10n/l10n.dart b/ui/flutter/lib/l10n/l10n.dart new file mode 100644 index 00000000..85294445 --- /dev/null +++ b/ui/flutter/lib/l10n/l10n.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; + +import 'app_localizations.dart'; + +export 'app_localizations.dart'; + +extension AppLocalizationsContext on BuildContext { + AppLocalizations get l10n => + Localizations.of(this, AppLocalizations) ?? + appLocalizationsFor('', systemLocale: Localizations.maybeLocaleOf(this)); +} + +Locale? localeFromConfig(String value) { + final normalized = value.trim().replaceAll('-', '_'); + if (normalized.isEmpty || normalized == 'system') return null; + final parts = normalized.split('_'); + return Locale.fromSubtags( + languageCode: parts.first.toLowerCase(), + countryCode: parts.length > 1 ? parts[1].toUpperCase() : null, + ); +} + +String localeConfigValue(Locale locale) { + final countryCode = locale.countryCode; + return countryCode == null || countryCode.isEmpty ? locale.languageCode : '${locale.languageCode}_$countryCode'; +} + +Locale? supportedLocaleFromConfig(String value) { + final locale = localeFromConfig(value); + if (locale == null) return null; + final configValue = localeConfigValue(locale); + return AppLocalizations.supportedLocales.any((candidate) => localeConfigValue(candidate) == configValue) + ? locale + : null; +} + +AppLocalizations appLocalizationsFor(String configValue, {Locale? systemLocale}) { + final configuredLocale = supportedLocaleFromConfig(configValue); + if (configuredLocale != null) return lookupAppLocalizations(configuredLocale); + + final locale = systemLocale ?? WidgetsBinding.instance.platformDispatcher.locale; + return lookupAppLocalizations(_supportedSystemLocale(locale) ?? const Locale('en')); +} + +Locale? _supportedSystemLocale(Locale locale) { + final exactValue = localeConfigValue(locale); + for (final candidate in AppLocalizations.supportedLocales) { + if (localeConfigValue(candidate) == exactValue) return candidate; + } + for (final candidate in AppLocalizations.supportedLocales) { + if (candidate.languageCode == locale.languageCode && candidate.countryCode == null) return candidate; + } + return null; +} diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 3e8b9f6f..33cd1c77 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -1,212 +1,63 @@ -import 'package:args/args.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'package:get/get.dart'; -import 'package:gopeed/util/analytics.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'api/api.dart' as api; -import 'app/modules/app/controllers/app_controller.dart'; -import 'app/rpc/webview_rpc_service.dart'; -import 'app/modules/app/views/app_view.dart'; -import 'core/libgopeed_boot.dart'; +import 'app/app.dart'; +import 'app/child_window_app.dart'; +import 'core/capabilities/app_capabilities.dart'; +import 'core/entry/app_initializer.dart'; +import 'core/entry/app_startup_options.dart'; +import 'core/window/app_window_bootstrap.dart'; +import 'core/window/app_window_launch_context.dart'; +import 'core/window/window_capability_transport.dart'; import 'database/database.dart'; -import 'i18n/message.dart'; -import 'util/browser_extension_host/browser_extension_host.dart'; -import 'util/locale_manager.dart'; +import 'util/analytics.dart'; import 'util/log_util.dart'; -import 'util/package_info.dart'; -import 'util/scheme_register/scheme_register.dart'; -import 'util/updater.dart'; import 'util/util.dart'; -import 'app/services/location_keep_alive_coordinator.dart'; -class StartupArgs { - static const flagHidden = "hidden"; - - /// Command line --hidden flag (for auto-start) - bool hiddenFromArgs = false; - - StartupArgs._(); - - /// Parse from command line arguments only - static StartupArgs parse(List arguments) { - final args = StartupArgs._(); - try { - final parser = ArgParser()..addFlag(flagHidden); - final results = parser.parse(arguments); - args.hiddenFromArgs = results.flag(flagHidden); - } catch (e) { - // ignore parse errors - } - return args; - } -} - -void main(List arguments) async { +Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); - - final args = StartupArgs.parse(arguments); - - await init(args); - onStart(); - - runApp(const AppView()); -} - -Future init(StartupArgs args) async { - // Note: WidgetsFlutterBinding.ensureInitialized() is already called in main() - if (Util.isMobile()) { + if (Util.isAndroid()) { FlutterForegroundTask.initCommunicationPort(); } - await Util.initStorageDir(); - await Database.instance.init(); - if (Util.isDesktop()) { - await windowManager.ensureInitialized(); - final windowState = Database.instance.getWindowState(); - // Check if menubar mode is enabled (only for macOS) - final runAsMenubarApp = - Util.isMacos() && Database.instance.getRunAsMenubarApp(); + final launchContext = AppWindowLaunchContext.fromArgs(args); - final windowOptions = WindowOptions( - size: Size(windowState?.width ?? 800, windowState?.height ?? 600), - center: true, - skipTaskbar: runAsMenubarApp, + if (launchContext.isSubWindow) { + final windowController = launchContext.buildController()!; + final session = await ChildWindowSession.connect(windowController); + await AppWindowBootstrap.setupSubWindow(launchContext.payload.type, localeConfig: session.appearance.value.locale); + runApp( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(session.capabilities)], + child: ChildWindowApp(payload: launchContext.payload, session: session), + ), ); - await windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.setPreventClose(true); - }); - - // Register Cmd+W hotkey on macOS to close window - if (Util.isMacos()) { - await hotKeyManager.unregisterAll(); - HotKey hotKey = HotKey( - key: PhysicalKeyboardKey.keyW, - modifiers: [HotKeyModifier.meta], - scope: HotKeyScope.inapp, - ); - await hotKeyManager.register( - hotKey, - keyDownHandler: (hotKey) { - windowManager.hide(); - }, - ); - } + return; } - initLogger(); - - try { - await initPackageInfo(); - } catch (e) { - logger.e("init package info fail", e); - } - - final controller = - Get.put(AppController(hiddenFromArgs: args.hiddenFromArgs)); - try { - await controller.loadStartConfig(); - final startCfg = controller.startConfig.value; - final webViewRpcConfig = await WebViewRpcService.instance.start(); - if (webViewRpcConfig != null) { - startCfg.webViewRpcConfig = webViewRpcConfig; - } - controller.runningPort.value = await LibgopeedBoot.instance.start(startCfg); - api.init(startCfg.network, controller.runningAddress(), startCfg.apiToken); - } catch (e) { - logger.e("libgopeed init fail", e); - } - - try { - await controller.loadDownloaderConfig(); - } catch (e) { - logger.e("load config fail", e); - } - - // Auto-start incomplete tasks if enabled - if (controller.downloaderConfig.value.extra.autoStartTasks) { + var startupOptions = AppStartupOptions.fromArgs(args); + if (Util.isDesktop() && !startupOptions.hidden) { try { - await api.continueAllTasks(null); - logger.i("auto-start tasks completed"); - } catch (e) { - logger.w("auto-start tasks fail", e); - } - } - - // iOS location keep-alive coordinator - if (Util.isIOS()) { - try { - await Get.putAsync(() async => LocationKeepAliveCoordinator()); - await Get.find().reconcile(); - Get.find().startPolling(); - } catch (e) { - logger.e("location keep-alive coordinator init fail", e); - } + startupOptions = startupOptions.withInitialUri(await AppLinks().getInitialLink()); + } catch (_) {} } - () async { - if (Util.isDesktop()) { - try { - registerUrlScheme("gopeed"); - if (controller.downloaderConfig.value.extra.defaultBtClient) { - registerDefaultTorrentClient(); - } - } catch (e) { - logger.e("register scheme fail", e); - } - - try { - await installHost(); - } catch (e) { - logger.e("browser extension host binary install fail", e); - } - for (final browser in Browser.values) { - try { - await installManifest(browser); - } catch (e) { - logger.e( - "browser [${browser.name}] extension host integration fail", e); - } - } - - try { - await installUpdater(); - } catch (e) { - logger.e("updater install fail", e); - } - } - }(); + await AppInitializer.ensureDatabaseInitialized(); + await AppWindowBootstrap.setupMainWindow(hidden: startupOptions.hidden); + runApp(const ProviderScope(child: GopeedApp())); + unawaited(_logAppOpen()); } -Future onStart() async { - // if is debug mode, check language message is complete,change debug locale to your comfortable language if you want - if (kDebugMode) { - final debugLang = getLocaleKey(debugLocale); - final fullMessages = messages.keys[debugLang]; - messages.keys.keys.where((e) => e != debugLang).forEach((lang) { - final langMessages = messages.keys[lang]; - if (langMessages == null) { - logger.w("missing language: $lang"); - return; - } - final missingKeys = - fullMessages!.keys.where((key) => langMessages[key] == null); - if (missingKeys.isNotEmpty) { - logger.w("missing language: $lang, keys: $missingKeys"); - } - }); - } - - if (Config.isConfigured && Database.instance.getAnalyticsEnabled()) { - try { - await Analytics.instance.init(); - await Analytics.instance.logAppOpen(); - } catch (e) { - logger.w("GA4 init failed: $e"); - } +Future _logAppOpen() async { + if (!AnalyticsConfig.isConfigured || !Database.instance.getAnalyticsEnabled()) return; + try { + await Analytics.instance.init(); + await Analytics.instance.logAppOpen(); + } catch (error) { + logger.w('GA4 init failed: $error'); } } diff --git a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart new file mode 100644 index 00000000..4673ec49 --- /dev/null +++ b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../../l10n/l10n.dart'; +import '../widgets/app_toast.dart'; + +class AppExitConfirmationController { + static const confirmationWindow = Duration(seconds: 2); + + DateTime? _lastBackAt; + + Future handleBack(BuildContext context) async { + final now = DateTime.now(); + final lastBackAt = _lastBackAt; + if (lastBackAt != null && now.difference(lastBackAt) <= confirmationWindow) { + _lastBackAt = null; + await SystemNavigator.pop(); + return; + } + + _lastBackAt = now; + showAppToast(context, context.l10n.pressBackAgainToExit); + } +} diff --git a/ui/flutter/lib/shared/services/download_directory_file_io.dart b/ui/flutter/lib/shared/services/download_directory_file_io.dart new file mode 100644 index 00000000..fc7a7b5c --- /dev/null +++ b/ui/flutter/lib/shared/services/download_directory_file_io.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; + +Future verifyDownloadDirectoryWritable(String directoryPath) async { + final probe = File(path.join(directoryPath, '.gopeed-write-test-${DateTime.now().microsecondsSinceEpoch}.tmp')); + try { + await probe.create(recursive: true); + await probe.writeAsString('test', flush: true); + } finally { + if (await probe.exists()) await probe.delete(); + } +} diff --git a/ui/flutter/lib/shared/services/download_directory_file_stub.dart b/ui/flutter/lib/shared/services/download_directory_file_stub.dart new file mode 100644 index 00000000..dcef4201 --- /dev/null +++ b/ui/flutter/lib/shared/services/download_directory_file_stub.dart @@ -0,0 +1,2 @@ +Future verifyDownloadDirectoryWritable(String directoryPath) => + Future.error(UnsupportedError('Directory write checks are unavailable on this platform')); diff --git a/ui/flutter/lib/shared/services/download_directory_host_platform_io.dart b/ui/flutter/lib/shared/services/download_directory_host_platform_io.dart new file mode 100644 index 00000000..869cf08d --- /dev/null +++ b/ui/flutter/lib/shared/services/download_directory_host_platform_io.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +TargetPlatform get downloadDirectoryHostPlatform { + if (Platform.isAndroid) return TargetPlatform.android; + if (Platform.isIOS) return TargetPlatform.iOS; + if (Platform.isWindows) return TargetPlatform.windows; + if (Platform.isLinux) return TargetPlatform.linux; + if (Platform.isMacOS) return TargetPlatform.macOS; + return defaultTargetPlatform; +} diff --git a/ui/flutter/lib/shared/services/download_directory_host_platform_stub.dart b/ui/flutter/lib/shared/services/download_directory_host_platform_stub.dart new file mode 100644 index 00000000..aadf41af --- /dev/null +++ b/ui/flutter/lib/shared/services/download_directory_host_platform_stub.dart @@ -0,0 +1,3 @@ +import 'package:flutter/foundation.dart'; + +TargetPlatform get downloadDirectoryHostPlatform => defaultTargetPlatform; diff --git a/ui/flutter/lib/shared/services/download_directory_picker.dart b/ui/flutter/lib/shared/services/download_directory_picker.dart new file mode 100644 index 00000000..3b73ccc2 --- /dev/null +++ b/ui/flutter/lib/shared/services/download_directory_picker.dart @@ -0,0 +1,224 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:external_path/external_path.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../l10n/l10n.dart'; +import '../theme/app_palette.dart'; +import 'download_directory_file_stub.dart' if (dart.library.io) 'download_directory_file_io.dart'; +import 'download_directory_host_platform_stub.dart' if (dart.library.io) 'download_directory_host_platform_io.dart'; + +/// Keeps directory selection compatible with the raw paths consumed by the Go +/// downloader. Android therefore exposes only paths whose writability can be +/// verified without relying on a persisted Storage Access Framework URI. +class DownloadDirectoryPicker { + DownloadDirectoryPicker._(); + + @visibleForTesting + static TargetPlatform? debugPlatformOverride; + + @visibleForTesting + static bool? debugWebOverride; + + @visibleForTesting + static Future?> Function()? debugAndroidLocationsLoader; + + @visibleForTesting + static Future Function(String path)? debugDownloadsPreparer; + + static TargetPlatform get _platform { + final override = debugPlatformOverride; + if (override != null) return override; + return downloadDirectoryHostPlatform; + } + + static bool get isWeb => debugWebOverride ?? kIsWeb; + + static bool get isAndroid => !isWeb && _platform == TargetPlatform.android; + + static bool get isIOS => !isWeb && _platform == TargetPlatform.iOS; + + static bool get isMobile => isAndroid || isIOS; + + static bool get canPick => !isWeb && _platform != TargetPlatform.iOS; + + static bool canEditManually({bool allowAndroidEditing = false}) { + return isWeb || !isMobile || (isAndroid && allowAndroidEditing); + } + + static Future pick(BuildContext context, {required String currentPath}) async { + if (isWeb) return null; + if (_platform == TargetPlatform.android) { + return _pickAndroid(context, currentPath: currentPath); + } + if (_platform == TargetPlatform.iOS) return null; + return FilePicker.platform.getDirectoryPath(); + } + + static Future _pickAndroid(BuildContext context, {required String currentPath}) async { + final locations = await _loadAndroidLocations(); + if (!context.mounted || locations == null) return null; + final applicationPath = locations['application']; + final downloadsPath = locations['downloads']; + if (applicationPath == null || applicationPath.isEmpty || downloadsPath == null || downloadsPath.isEmpty) { + return null; + } + + String? errorMessage; + var checkingDownloads = false; + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) { + Future chooseDownloads() async { + if (checkingDownloads) return; + setDialogState(() { + checkingDownloads = true; + errorMessage = null; + }); + try { + final verifiedPath = await _prepareDownloads(downloadsPath); + if (dialogContext.mounted && verifiedPath.isNotEmpty) { + shad.closeOverlay(dialogContext, verifiedPath); + } + } catch (_) { + if (dialogContext.mounted) { + setDialogState(() => errorMessage = dialogContext.l10n.downloadsDirectoryUnavailable); + } + } finally { + if (dialogContext.mounted) setDialogState(() => checkingDownloads = false); + } + } + + return shad.AlertDialog( + key: const ValueKey('android-download-directory-dialog'), + title: Text(dialogContext.l10n.chooseDownloadDirectory), + content: SizedBox( + width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 400.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DirectoryOption( + key: const ValueKey('android-app-directory-option'), + icon: Icons.smartphone_outlined, + title: dialogContext.l10n.applicationStorage, + description: dialogContext.l10n.applicationStorageDescription, + selected: currentPath == applicationPath, + onPressed: checkingDownloads ? null : () => shad.closeOverlay(dialogContext, applicationPath), + ), + const SizedBox(height: 8), + _DirectoryOption( + key: const ValueKey('android-downloads-directory-option'), + icon: Icons.download_outlined, + title: 'Download/Gopeed', + description: dialogContext.l10n.publicDownloadsDescription, + selected: currentPath == downloadsPath, + loading: checkingDownloads, + onPressed: checkingDownloads ? null : chooseDownloads, + ), + if (errorMessage != null) ...[ + const SizedBox(height: 8), + Text( + errorMessage!, + style: TextStyle(color: AppPalette.of(dialogContext).error, fontSize: 12, height: 1.3), + ), + ], + ], + ), + ), + actions: [ + shad.SecondaryButton( + onPressed: checkingDownloads ? null : () => shad.closeOverlay(dialogContext), + child: Text(dialogContext.l10n.cancel), + ), + ], + ); + }, + ), + ); + return overlay.future; + } + + static Future?> _loadAndroidLocations() async { + final debugLoader = debugAndroidLocationsLoader; + if (debugLoader != null) return debugLoader(); + final applicationDirectory = await getExternalStorageDirectory() ?? await getApplicationDocumentsDirectory(); + final downloadsDirectory = await ExternalPath.getExternalStoragePublicDirectory(ExternalPath.DIRECTORY_DOWNLOAD); + if (downloadsDirectory.isEmpty) return null; + return {'application': applicationDirectory.path, 'downloads': path.join(downloadsDirectory, 'Gopeed')}; + } + + static Future _prepareDownloads(String downloadsPath) async { + final debugPreparer = debugDownloadsPreparer; + if (debugPreparer != null) return debugPreparer(downloadsPath); + final androidInfo = await DeviceInfoPlugin().androidInfo; + if (androidInfo.version.sdkInt < 30) { + var permission = await Permission.storage.status; + if (!permission.isGranted) permission = await Permission.storage.request(); + if (!permission.isGranted) throw StateError('Storage permission was denied'); + } + await verifyDownloadDirectoryWritable(downloadsPath); + return downloadsPath; + } +} + +class _DirectoryOption extends StatelessWidget { + const _DirectoryOption({ + super.key, + required this.icon, + required this.title, + required this.description, + required this.selected, + required this.onPressed, + this.loading = false, + }); + + final IconData icon; + final String title; + final String description; + final bool selected; + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return shad.SecondaryButton( + onPressed: onPressed, + child: SizedBox( + width: double.infinity, + child: Row( + children: [ + Icon(icon, size: 20, color: palette.textSecondary), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(color: palette.textPrimary, fontSize: 13)), + const SizedBox(height: 2), + Text(description, style: TextStyle(color: palette.textSecondary, fontSize: 11, height: 1.25)), + ], + ), + ), + const SizedBox(width: 8), + if (loading) + const SizedBox.square(dimension: 15, child: shad.CircularProgressIndicator()) + else if (selected) + Icon(Icons.check_circle_outline, size: 18, color: palette.success), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/theme/app_component_themes.dart b/ui/flutter/lib/shared/theme/app_component_themes.dart new file mode 100644 index 00000000..5b0915a0 --- /dev/null +++ b/ui/flutter/lib/shared/theme/app_component_themes.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import 'app_design_tokens.dart'; +import 'app_palette.dart'; + +class AppComponentThemes extends StatelessWidget { + const AppComponentThemes({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = shad.Theme.of(context); + final palette = AppPalette.of(context); + return shad.ComponentTheme( + data: shad.FocusOutlineTheme( + align: 1, + border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1), + ), + child: shad.ComponentTheme( + data: shad.SecondaryButtonTheme( + decoration: (context, states, value) { + final decoration = switch (value) { + final BoxDecoration decoration => decoration, + _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), + }; + if (states.contains(WidgetState.disabled)) { + return decoration.copyWith(color: palette.surfaceSoft); + } + if (theme.brightness == Brightness.light) { + final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); + final hoverBackground = Color.alphaBlend( + palette.textPrimary.withValues(alpha: 0.13), + palette.surfaceSoft, + ); + return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); + } + return value; + }, + textStyle: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, + iconTheme: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, + ), + child: shad.ComponentTheme( + data: shad.CheckboxTheme( + size: AppDesignTokens.checkboxSize, + gap: AppDesignTokens.checkboxLabelGap, + borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + ), + child: shad.ComponentTheme( + data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), + child: child, + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/theme/app_design_tokens.dart b/ui/flutter/lib/shared/theme/app_design_tokens.dart new file mode 100644 index 00000000..7847316d --- /dev/null +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; + +class AppDesignTokens { + const AppDesignTokens._(); + + static const double windowHeaderHeight = 30; + static const double windowRadius = 12; + static const double railWidth = 68; + static const double filterSidebarWidth = 192; + static const double contentHeaderHeight = 48; + static const double taskRowHeight = 72; + static const double controlRadius = 4; + static const double checkboxSize = 18; + static const double checkboxLabelGap = 10; + static const double taskDetailsDrawerMinWidth = 440; + static const double taskDetailsDrawerMaxWidth = 720; + static const double taskDetailsDrawerViewportRatio = 0.42; + static const double taskDetailsDesktopPadding = 24; + static const double taskDetailsMobilePadding = 16; + static const double settingsContentMaxWidth = 920; + static const double settingsFormControlWidth = 420; + static const double settingsNumberControlWidth = 168; + static const double settingsItemMinLabelWidth = 160; + + static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32); + static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16); + + static const double space4 = 4; + static const double space8 = 8; + static const double space12 = 12; + static const double space16 = 16; + static const double space24 = 24; + static const double space32 = 32; + + static double taskDetailsDrawerWidth(double viewportWidth) { + return (viewportWidth * taskDetailsDrawerViewportRatio) + .clamp(taskDetailsDrawerMinWidth, taskDetailsDrawerMaxWidth) + .toDouble(); + } +} diff --git a/ui/flutter/lib/shared/theme/app_palette.dart b/ui/flutter/lib/shared/theme/app_palette.dart new file mode 100644 index 00000000..d1dd3d10 --- /dev/null +++ b/ui/flutter/lib/shared/theme/app_palette.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import 'app_theme_color.dart'; + +class AppPalette extends ThemeExtension { + const AppPalette({ + required this.bg, + required this.railBg, + required this.sideBg, + required this.cardBg, + required this.cardHoverBg, + required this.surfaceSoft, + required this.inputBg, + required this.footerBg, + required this.border, + required this.textPrimary, + required this.textSecondary, + required this.textMuted, + required this.primaryActionBg, + required this.primaryActionForeground, + required this.primaryActionHoverBg, + required this.brand, + required this.brandForeground, + required this.brandSoft, + required this.brandTrack, + required this.brandProgress, + required this.accent, + required this.accentOn, + required this.error, + required this.success, + required this.toggleBg, + required this.toggleIcon, + required this.itemActiveBg, + required this.filterActiveBg, + required this.progressTrack, + required this.taskCardSelectedBorder, + required this.taskCardHoverBg, + required this.taskCardAction, + required this.taskCardProgressFill, + required this.taskCardIconBg, + required this.taskCardFailedIconBg, + required this.taskMeta, + required this.taskMetaSubtle, + required this.taskErrorMeta, + required this.searchHint, + required this.headerDivider, + required this.captionButtonHover, + required this.captionButtonCloseHover, + }); + + static const Color lightAccent = Color(0xFF79C476); + + static const light = AppPalette( + bg: Color(0xFFF6F8F7), + railBg: Color(0xFFE8EEEA), + sideBg: Color(0xFFF0F4F1), + cardBg: Color(0xFFFFFFFF), + cardHoverBg: Color(0xFFF5F7F5), + surfaceSoft: Color(0xFFEDF0EE), + inputBg: Color(0xFFE7E8E9), + footerBg: Color(0xFFF3F4F5), + border: Color(0x66C0C9BA), + textPrimary: Color(0xFF191C1D), + textSecondary: Color(0xFF5F6368), + textMuted: Color(0xFF707A6D), + primaryActionBg: Color(0xFF191C1D), + primaryActionForeground: Color(0xFFFFFFFF), + primaryActionHoverBg: Color(0xFF303435), + brand: lightAccent, + brandForeground: Color(0xFFFFFFFF), + brandSoft: Color(0x1479C476), + brandTrack: Color(0x2679C476), + brandProgress: Color(0xFF79C476), + accent: Color(0xFFE7E8E9), + accentOn: Color(0xFFF4F7F6), + error: Color(0xFFBA1A1A), + success: Color(0xFF2E7D32), + toggleBg: Color(0xFFFFFFFF), + toggleIcon: Color(0xFF5F6368), + itemActiveBg: Color(0x0F191C1D), + filterActiveBg: Color(0x3379C476), + progressTrack: Color(0x33C0C9BA), + taskCardSelectedBorder: Color(0x24191C1D), + taskCardHoverBg: Color(0xFFEAF0EC), + taskCardAction: Color(0xFF5F6368), + taskCardProgressFill: Color(0xFF79C476), + taskCardIconBg: Color(0xFFF5F7F5), + taskCardFailedIconBg: Color(0x14BA1A1A), + taskMeta: Color(0xFF5F6368), + taskMetaSubtle: Color(0xFF707A6D), + taskErrorMeta: Color(0xCCBA1A1A), + searchHint: Color(0xFF707A6D), + headerDivider: Color(0xA3C0C9BA), + captionButtonHover: Color(0x0F191C1D), + captionButtonCloseHover: Color(0xFFE81123), + ); + + static final dark = AppPalette( + bg: const Color(0xFF09090B), + railBg: Colors.black, + sideBg: const Color(0xFF111111), + cardBg: const Color(0xFF0F0F11), + cardHoverBg: const Color(0xFF121215), + surfaceSoft: const Color(0xFF1F1F24), + inputBg: const Color(0xFF111111), + footerBg: Colors.black.withValues(alpha: 0.2), + border: const Color(0xFF1B1B1D), + textPrimary: const Color(0xFFFFFFFF), + textSecondary: const Color(0xFFA1A1AA), + textMuted: const Color(0xFF71717A), + primaryActionBg: const Color(0xFFFAFAFA), + primaryActionForeground: const Color(0xFF18181B), + primaryActionHoverBg: const Color(0xFFE4E4E7), + brand: const Color(0xFFA7F5A2), + brandForeground: const Color(0xFF07310B), + brandSoft: const Color(0x1AA7F5A2), + brandTrack: const Color(0x2CA7F5A2), + brandProgress: const Color(0xFFA7F5A2), + accent: const Color(0xFF111111), + accentOn: const Color(0xFF09090B), + error: const Color(0xFFFFB4AB), + success: const Color(0xFF81C784), + toggleBg: Colors.white.withValues(alpha: 0.08), + toggleIcon: const Color(0xFFA1A1AA), + itemActiveBg: Colors.white.withValues(alpha: 0.08), + filterActiveBg: const Color(0x3DA7F5A2), + progressTrack: const Color(0xFF272729), + taskCardSelectedBorder: const Color(0xFF3F3F41), + taskCardHoverBg: const Color(0xFF171719), + taskCardAction: const Color(0xFFC6C6C6), + taskCardProgressFill: const Color(0xFFA7F5A2), + taskCardIconBg: const Color(0xFF1B1B1E), + taskCardFailedIconBg: const Color(0xFF272020), + taskMeta: const Color(0xFF8F8F90), + taskMetaSubtle: const Color(0xFF6B6B6C), + taskErrorMeta: const Color(0xFFCF938C), + searchHint: const Color(0xFF52525B), + headerDivider: const Color(0x1AFFFFFF), + captionButtonHover: const Color(0x0DFFFFFF), + captionButtonCloseHover: const Color(0xFFE81123), + ); + + static AppPalette lightFor(AppThemeColor themeColor) { + if (themeColor == AppThemeColor.green) return light; + return _withThemeColor(light, color: themeColor.light, foreground: themeColor.lightForeground, dark: false); + } + + static AppPalette darkFor(AppThemeColor themeColor) { + if (themeColor == AppThemeColor.green) return dark; + return _withThemeColor(dark, color: themeColor.dark, foreground: themeColor.darkForeground, dark: true); + } + + static AppPalette _withThemeColor( + AppPalette palette, { + required Color color, + required Color foreground, + required bool dark, + }) { + return palette.copyWith( + brand: color, + brandForeground: foreground, + brandSoft: color.withValues(alpha: dark ? 0.10 : 0.08), + brandTrack: color.withValues(alpha: dark ? 0.17 : 0.15), + brandProgress: color, + filterActiveBg: color.withValues(alpha: dark ? 0.24 : 0.20), + taskCardProgressFill: color, + ); + } + + final Color bg; + final Color railBg; + final Color sideBg; + final Color cardBg; + final Color cardHoverBg; + final Color surfaceSoft; + final Color inputBg; + final Color footerBg; + final Color border; + final Color textPrimary; + final Color textSecondary; + final Color textMuted; + final Color primaryActionBg; + final Color primaryActionForeground; + final Color primaryActionHoverBg; + final Color brand; + final Color brandForeground; + final Color brandSoft; + final Color brandTrack; + final Color brandProgress; + final Color accent; + final Color accentOn; + final Color error; + final Color success; + final Color toggleBg; + final Color toggleIcon; + final Color itemActiveBg; + final Color filterActiveBg; + final Color progressTrack; + final Color taskCardSelectedBorder; + final Color taskCardHoverBg; + final Color taskCardAction; + final Color taskCardProgressFill; + final Color taskCardIconBg; + final Color taskCardFailedIconBg; + final Color taskMeta; + final Color taskMetaSubtle; + final Color taskErrorMeta; + final Color searchHint; + final Color headerDivider; + final Color captionButtonHover; + final Color captionButtonCloseHover; + + static AppPalette of(BuildContext context) { + final extension = Theme.of(context).extension(); + if (extension != null) { + return extension; + } + return shad.Theme.of(context).brightness == Brightness.light ? light : dark; + } + + @override + AppPalette copyWith({ + Color? bg, + Color? railBg, + Color? sideBg, + Color? cardBg, + Color? cardHoverBg, + Color? surfaceSoft, + Color? inputBg, + Color? footerBg, + Color? border, + Color? textPrimary, + Color? textSecondary, + Color? textMuted, + Color? primaryActionBg, + Color? primaryActionForeground, + Color? primaryActionHoverBg, + Color? brand, + Color? brandForeground, + Color? brandSoft, + Color? brandTrack, + Color? brandProgress, + Color? accent, + Color? accentOn, + Color? error, + Color? success, + Color? toggleBg, + Color? toggleIcon, + Color? itemActiveBg, + Color? filterActiveBg, + Color? progressTrack, + Color? taskCardSelectedBorder, + Color? taskCardHoverBg, + Color? taskCardAction, + Color? taskCardProgressFill, + Color? taskCardIconBg, + Color? taskCardFailedIconBg, + Color? taskMeta, + Color? taskMetaSubtle, + Color? taskErrorMeta, + Color? searchHint, + Color? headerDivider, + Color? captionButtonHover, + Color? captionButtonCloseHover, + }) { + return AppPalette( + bg: bg ?? this.bg, + railBg: railBg ?? this.railBg, + sideBg: sideBg ?? this.sideBg, + cardBg: cardBg ?? this.cardBg, + cardHoverBg: cardHoverBg ?? this.cardHoverBg, + surfaceSoft: surfaceSoft ?? this.surfaceSoft, + inputBg: inputBg ?? this.inputBg, + footerBg: footerBg ?? this.footerBg, + border: border ?? this.border, + textPrimary: textPrimary ?? this.textPrimary, + textSecondary: textSecondary ?? this.textSecondary, + textMuted: textMuted ?? this.textMuted, + primaryActionBg: primaryActionBg ?? this.primaryActionBg, + primaryActionForeground: primaryActionForeground ?? this.primaryActionForeground, + primaryActionHoverBg: primaryActionHoverBg ?? this.primaryActionHoverBg, + brand: brand ?? this.brand, + brandForeground: brandForeground ?? this.brandForeground, + brandSoft: brandSoft ?? this.brandSoft, + brandTrack: brandTrack ?? this.brandTrack, + brandProgress: brandProgress ?? this.brandProgress, + accent: accent ?? this.accent, + accentOn: accentOn ?? this.accentOn, + error: error ?? this.error, + success: success ?? this.success, + toggleBg: toggleBg ?? this.toggleBg, + toggleIcon: toggleIcon ?? this.toggleIcon, + itemActiveBg: itemActiveBg ?? this.itemActiveBg, + filterActiveBg: filterActiveBg ?? this.filterActiveBg, + progressTrack: progressTrack ?? this.progressTrack, + taskCardSelectedBorder: taskCardSelectedBorder ?? this.taskCardSelectedBorder, + taskCardHoverBg: taskCardHoverBg ?? this.taskCardHoverBg, + taskCardAction: taskCardAction ?? this.taskCardAction, + taskCardProgressFill: taskCardProgressFill ?? this.taskCardProgressFill, + taskCardIconBg: taskCardIconBg ?? this.taskCardIconBg, + taskCardFailedIconBg: taskCardFailedIconBg ?? this.taskCardFailedIconBg, + taskMeta: taskMeta ?? this.taskMeta, + taskMetaSubtle: taskMetaSubtle ?? this.taskMetaSubtle, + taskErrorMeta: taskErrorMeta ?? this.taskErrorMeta, + searchHint: searchHint ?? this.searchHint, + headerDivider: headerDivider ?? this.headerDivider, + captionButtonHover: captionButtonHover ?? this.captionButtonHover, + captionButtonCloseHover: captionButtonCloseHover ?? this.captionButtonCloseHover, + ); + } + + @override + AppPalette lerp(ThemeExtension? other, double t) { + if (other is! AppPalette) { + return this; + } + return AppPalette( + bg: Color.lerp(bg, other.bg, t)!, + railBg: Color.lerp(railBg, other.railBg, t)!, + sideBg: Color.lerp(sideBg, other.sideBg, t)!, + cardBg: Color.lerp(cardBg, other.cardBg, t)!, + cardHoverBg: Color.lerp(cardHoverBg, other.cardHoverBg, t)!, + surfaceSoft: Color.lerp(surfaceSoft, other.surfaceSoft, t)!, + inputBg: Color.lerp(inputBg, other.inputBg, t)!, + footerBg: Color.lerp(footerBg, other.footerBg, t)!, + border: Color.lerp(border, other.border, t)!, + textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!, + textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!, + textMuted: Color.lerp(textMuted, other.textMuted, t)!, + primaryActionBg: Color.lerp(primaryActionBg, other.primaryActionBg, t)!, + primaryActionForeground: Color.lerp(primaryActionForeground, other.primaryActionForeground, t)!, + primaryActionHoverBg: Color.lerp(primaryActionHoverBg, other.primaryActionHoverBg, t)!, + brand: Color.lerp(brand, other.brand, t)!, + brandForeground: Color.lerp(brandForeground, other.brandForeground, t)!, + brandSoft: Color.lerp(brandSoft, other.brandSoft, t)!, + brandTrack: Color.lerp(brandTrack, other.brandTrack, t)!, + brandProgress: Color.lerp(brandProgress, other.brandProgress, t)!, + accent: Color.lerp(accent, other.accent, t)!, + accentOn: Color.lerp(accentOn, other.accentOn, t)!, + error: Color.lerp(error, other.error, t)!, + success: Color.lerp(success, other.success, t)!, + toggleBg: Color.lerp(toggleBg, other.toggleBg, t)!, + toggleIcon: Color.lerp(toggleIcon, other.toggleIcon, t)!, + itemActiveBg: Color.lerp(itemActiveBg, other.itemActiveBg, t)!, + filterActiveBg: Color.lerp(filterActiveBg, other.filterActiveBg, t)!, + progressTrack: Color.lerp(progressTrack, other.progressTrack, t)!, + taskCardSelectedBorder: Color.lerp(taskCardSelectedBorder, other.taskCardSelectedBorder, t)!, + taskCardHoverBg: Color.lerp(taskCardHoverBg, other.taskCardHoverBg, t)!, + taskCardAction: Color.lerp(taskCardAction, other.taskCardAction, t)!, + taskCardProgressFill: Color.lerp(taskCardProgressFill, other.taskCardProgressFill, t)!, + taskCardIconBg: Color.lerp(taskCardIconBg, other.taskCardIconBg, t)!, + taskCardFailedIconBg: Color.lerp(taskCardFailedIconBg, other.taskCardFailedIconBg, t)!, + taskMeta: Color.lerp(taskMeta, other.taskMeta, t)!, + taskMetaSubtle: Color.lerp(taskMetaSubtle, other.taskMetaSubtle, t)!, + taskErrorMeta: Color.lerp(taskErrorMeta, other.taskErrorMeta, t)!, + searchHint: Color.lerp(searchHint, other.searchHint, t)!, + headerDivider: Color.lerp(headerDivider, other.headerDivider, t)!, + captionButtonHover: Color.lerp(captionButtonHover, other.captionButtonHover, t)!, + captionButtonCloseHover: Color.lerp(captionButtonCloseHover, other.captionButtonCloseHover, t)!, + ); + } +} diff --git a/ui/flutter/lib/shared/theme/app_theme.dart b/ui/flutter/lib/shared/theme/app_theme.dart new file mode 100644 index 00000000..21760237 --- /dev/null +++ b/ui/flutter/lib/shared/theme/app_theme.dart @@ -0,0 +1,117 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/painting.dart' show TextStyle; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import 'app_palette.dart'; +import 'app_theme_color.dart'; + +class AppTheme { + const AppTheme._(); + + // Flutter's automatic CJK fallback can select faces with mismatched weights + // on Windows. Keep Geist for supported glyphs, but make the native Windows + // UI font the deterministic fallback for Chinese text. + static const _windowsFontFamilyFallback = ['Microsoft YaHei UI']; + + static bool get _usesWindowsFontFallback => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; + + static shad.Typography _typography() { + if (defaultTargetPlatform == TargetPlatform.android) return _androidTypography; + + const typography = shad.Typography.geist(); + if (!_usesWindowsFontFallback) return typography; + + return typography.copyWith( + // Do not pass `package` here: Flutter prefixes every fallback family + // with the package name when it is set, including system font names. + sans: () => const TextStyle( + fontFamily: 'packages/shadcn_flutter/GeistSans', + fontFamilyFallback: _windowsFontFamilyFallback, + ), + ); + } + + static shad.ThemeData light([AppThemeColor themeColor = AppThemeColor.green]) { + return _buildTheme(palette: AppPalette.lightFor(themeColor), brightness: Brightness.light); + } + + static shad.ThemeData dark([AppThemeColor themeColor = AppThemeColor.green]) { + return _buildTheme(palette: AppPalette.darkFor(themeColor), brightness: Brightness.dark); + } + + static shad.ThemeData _buildTheme({required AppPalette palette, required Brightness brightness}) { + return shad.ThemeData( + colorScheme: shad.ColorScheme.fromColors( + brightness: brightness, + colors: { + 'background': palette.bg, + 'foreground': palette.textPrimary, + 'card': palette.cardBg, + 'cardForeground': palette.textPrimary, + 'popover': palette.sideBg, + 'popoverForeground': palette.textPrimary, + 'primary': palette.brand, + 'primaryForeground': palette.brandForeground, + 'secondary': palette.surfaceSoft, + 'secondaryForeground': palette.textPrimary, + 'muted': palette.surfaceSoft, + 'mutedForeground': palette.textSecondary, + 'accent': palette.inputBg, + 'accentForeground': palette.textPrimary, + 'destructive': palette.error, + 'destructiveForeground': palette.bg, + 'border': palette.border, + 'input': palette.border, + 'ring': palette.brand, + 'chart1': palette.brand, + 'chart2': palette.textSecondary, + 'chart3': palette.textMuted, + 'chart4': palette.surfaceSoft, + 'chart5': palette.cardHoverBg, + }, + ), + typography: _typography(), + radius: 0.5, + scaling: 1, + surfaceBlur: 0, + surfaceOpacity: 1, + platform: defaultTargetPlatform, + ); + } + + static const shad.Typography _androidTypography = shad.Typography.geist( + sans: material.TextStyle(), + mono: material.TextStyle(fontFamily: 'monospace'), + inlineCode: material.TextStyle(fontFamily: 'monospace', fontSize: 14, fontWeight: material.FontWeight.w600), + ); + + static material.ThemeData materialLight([AppThemeColor themeColor = AppThemeColor.green]) { + return _buildMaterialTheme(palette: AppPalette.lightFor(themeColor), brightness: Brightness.light); + } + + static material.ThemeData materialDark([AppThemeColor themeColor = AppThemeColor.green]) { + return _buildMaterialTheme(palette: AppPalette.darkFor(themeColor), brightness: Brightness.dark); + } + + static material.ThemeData _buildMaterialTheme({required AppPalette palette, required Brightness brightness}) { + final theme = material.ThemeData.from( + colorScheme: material.ColorScheme.fromSeed( + seedColor: palette.brand, + brightness: brightness, + surface: palette.bg, + primary: palette.brand, + ), + useMaterial3: true, + ); + return theme.copyWith( + extensions: >[palette], + textTheme: _usesWindowsFontFallback + ? theme.textTheme.apply(fontFamilyFallback: _windowsFontFamilyFallback) + : theme.textTheme, + primaryTextTheme: _usesWindowsFontFallback + ? theme.primaryTextTheme.apply(fontFamilyFallback: _windowsFontFamilyFallback) + : theme.primaryTextTheme, + ); + } +} diff --git a/ui/flutter/lib/shared/theme/app_theme_color.dart b/ui/flutter/lib/shared/theme/app_theme_color.dart new file mode 100644 index 00000000..4bc2e27f --- /dev/null +++ b/ui/flutter/lib/shared/theme/app_theme_color.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart'; + +enum AppThemeColor { + green( + key: 'green', + light: Color(0xFF79C476), + dark: Color(0xFFA7F5A2), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF07310B), + ), + red( + key: 'red', + light: Color(0xFFE5484D), + dark: Color(0xFFFF8A8E), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF3B0507), + ), + rose( + key: 'rose', + light: Color(0xFFE93D82), + dark: Color(0xFFFF8AB7), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF3B071B), + ), + orange( + key: 'orange', + light: Color(0xFFF76B15), + dark: Color(0xFFFFA45B), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF3A1600), + ), + cyan( + key: 'cyan', + light: Color(0xFF0CA678), + dark: Color(0xFF5EE0B5), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF003829), + ), + blue( + key: 'blue', + light: Color(0xFF2563EB), + dark: Color(0xFF73A7FF), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF061B3F), + ), + amber( + key: 'amber', + light: Color(0xFFD99A00), + dark: Color(0xFFFFD166), + lightForeground: Color(0xFF211800), + darkForeground: Color(0xFF302300), + ), + purple( + key: 'purple', + light: Color(0xFF8B5CF6), + dark: Color(0xFFB794F6), + lightForeground: Color(0xFFFFFFFF), + darkForeground: Color(0xFF261052), + ); + + const AppThemeColor({ + required this.key, + required this.light, + required this.dark, + required this.lightForeground, + required this.darkForeground, + }); + + final String key; + final Color light; + final Color dark; + final Color lightForeground; + final Color darkForeground; + + static AppThemeColor fromKey(String? key) { + return values.where((color) => color.key == key).firstOrNull ?? AppThemeColor.green; + } +} + +class AppThemePreviewColors { + const AppThemePreviewColors._(); + + static const lightBackground = Color(0xFFF7F8F8); + static const lightSide = Color(0xFFE5E8E7); + static const lightLine = Color(0xFFB8BEBC); + static const darkBackground = Color(0xFF111318); + static const darkSide = Color(0xFF20242C); + static const darkLine = Color(0xFF676D78); + static const systemSide = Color(0x55878E8C); + static const systemLine = Color(0xFF7B8280); + static const transparent = Color(0x00000000); +} diff --git a/ui/flutter/lib/shared/widgets/action_icon_button.dart b/ui/flutter/lib/shared/widgets/action_icon_button.dart new file mode 100644 index 00000000..b514a8ac --- /dev/null +++ b/ui/flutter/lib/shared/widgets/action_icon_button.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +class ActionIconButton extends StatelessWidget { + const ActionIconButton({super.key, required this.icon, required this.color, this.onTap}); + + final IconData icon; + final Color color; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return shad.IconButton.ghost( + onPressed: onTap, + density: shad.ButtonDensity.compact, + icon: Icon(icon, color: color, size: 18), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart b/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart new file mode 100644 index 00000000..b40d1fc1 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart @@ -0,0 +1,100 @@ +import 'package:flutter/widgets.dart'; + +import '../../core/utils/breakpoints.dart'; +import '../theme/app_design_tokens.dart'; +import '../theme/app_palette.dart'; + +class AppChoiceOption { + const AppChoiceOption({required this.value, required this.label, required this.icon, this.key}); + + final T value; + final String label; + final IconData icon; + final String? key; +} + +class AppChoiceSegmentedControl extends StatelessWidget { + const AppChoiceSegmentedControl({ + super.key, + required this.value, + required this.options, + required this.onChanged, + this.buttonKeyPrefix = 'choice', + this.alignment, + }); + + final T value; + final List> options; + final ValueChanged onChanged; + final String buttonKeyPrefix; + final WrapAlignment? alignment; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: alignment ?? (desktop ? WrapAlignment.end : WrapAlignment.start), + children: [ + for (final option in options) + _ChoiceButton( + key: ValueKey('$buttonKeyPrefix-${option.key ?? option.value}'), + option: option, + selected: option.value == value, + palette: palette, + onPressed: () => onChanged(option.value), + ), + ], + ); + } +} + +class _ChoiceButton extends StatelessWidget { + const _ChoiceButton({ + super.key, + required this.option, + required this.selected, + required this.palette, + required this.onPressed, + }); + + final AppChoiceOption option; + final bool selected; + final AppPalette palette; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: selected ? palette.itemActiveBg : palette.cardBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + border: Border.all(color: selected ? palette.textPrimary : palette.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary), + const SizedBox(width: 7), + Text( + option.label, + style: TextStyle( + color: selected ? palette.textPrimary : palette.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart new file mode 100644 index 00000000..9ccf53ca --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../theme/app_design_tokens.dart'; +import '../theme/app_palette.dart'; +import '../../l10n/l10n.dart'; + +class AppHttpHeadersController extends ChangeNotifier { + AppHttpHeadersController({Map? headers, Iterable defaultNames = const []}) { + if (headers != null && headers.isNotEmpty) { + _rows.addAll(headers.entries.map((entry) => _HttpHeaderRowControllers(name: entry.key, value: entry.value))); + } else { + _rows.addAll(defaultNames.map((name) => _HttpHeaderRowControllers(name: name))); + } + _ensureRow(); + } + + final List<_HttpHeaderRowControllers> _rows = []; + + void replace(Map headers) { + for (final row in _rows) { + row.dispose(); + } + _rows + ..clear() + ..addAll(headers.entries.map((entry) => _HttpHeaderRowControllers(name: entry.key, value: entry.value))); + _ensureRow(); + notifyListeners(); + } + + Map toMap({bool requireValue = false}) { + final headers = {}; + for (final row in _rows) { + final name = row.name.text.trim(); + final value = row.value.text.trim(); + if (name.isNotEmpty && (!requireValue || value.isNotEmpty)) { + headers[name] = value; + } + } + return headers; + } + + void add() { + _rows.add(_HttpHeaderRowControllers()); + notifyListeners(); + } + + void removeAt(int index) { + if (_rows.length == 1) return; + _rows.removeAt(index).dispose(); + notifyListeners(); + } + + void _ensureRow() { + if (_rows.isEmpty) { + _rows.add(_HttpHeaderRowControllers()); + } + } + + @override + void dispose() { + for (final row in _rows) { + row.dispose(); + } + super.dispose(); + } +} + +class AppHttpHeadersEditor extends StatelessWidget { + const AppHttpHeadersEditor({super.key, required this.controller, required this.label, required this.keyPrefix}); + + final AppHttpHeadersController controller; + final String label; + final String keyPrefix; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Row( + key: ValueKey('$keyPrefix-editor'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + label, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: AnimatedBuilder( + animation: controller, + builder: (context, _) => Column( + children: [ + for (var index = 0; index < controller._rows.length; index++) ...[ + Row( + children: [ + Expanded( + flex: 382, + child: _HeaderTextField( + key: ValueKey('$keyPrefix-name-$index'), + controller: controller._rows[index].name, + hintText: context.l10n.httpHeaderName, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 618, + child: _HeaderTextField( + key: ValueKey('$keyPrefix-value-$index'), + controller: controller._rows[index].value, + hintText: context.l10n.httpHeaderValue, + ), + ), + const SizedBox(width: 4), + _HeaderAction( + key: ValueKey('$keyPrefix-remove-$index'), + icon: Icons.remove, + onPressed: controller._rows.length == 1 ? null : () => controller.removeAt(index), + ), + const SizedBox(width: 4), + Visibility( + visible: index == controller._rows.length - 1, + maintainAnimation: true, + maintainSize: true, + maintainState: true, + child: _HeaderAction( + key: index == controller._rows.length - 1 ? ValueKey('$keyPrefix-add') : null, + icon: Icons.add, + onPressed: controller.add, + ), + ), + ], + ), + if (index != controller._rows.length - 1) const SizedBox(height: 8), + ], + ], + ), + ), + ), + ], + ); + } +} + +class _HttpHeaderRowControllers { + _HttpHeaderRowControllers({String name = '', String value = ''}) + : name = TextEditingController(text: name), + value = TextEditingController(text: value); + + final TextEditingController name; + final TextEditingController value; + + void dispose() { + name.dispose(); + value.dispose(); + } +} + +class _HeaderTextField extends StatelessWidget { + const _HeaderTextField({super.key, required this.controller, required this.hintText}); + + final TextEditingController controller; + final String hintText; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return shad.TextField( + controller: controller, + hintText: hintText, + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ); + } +} + +class _HeaderAction extends StatelessWidget { + const _HeaderAction({super.key, required this.icon, this.onPressed}); + + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: onPressed, + child: Icon(icon, size: 14, color: palette.textSecondary), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_loading_button.dart b/ui/flutter/lib/shared/widgets/app_loading_button.dart new file mode 100644 index 00000000..81521d6b --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_loading_button.dart @@ -0,0 +1,108 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../theme/app_design_tokens.dart'; +import '../theme/app_palette.dart'; +import 'app_primary_button.dart'; + +enum AppLoadingButtonVariant { secondary, primary, brand } + +class AppLoadingButton extends StatelessWidget { + const AppLoadingButton({ + super.key, + required this.onPressed, + required this.loading, + required this.child, + this.icon, + this.alignment, + this.variant = AppLoadingButtonVariant.secondary, + }); + + final VoidCallback? onPressed; + final bool loading; + final Widget child; + final Widget? icon; + final AlignmentGeometry? alignment; + final AppLoadingButtonVariant variant; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final replaceChild = loading && icon == null; + final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon; + final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child; + final effectiveOnPressed = loading ? null : onPressed; + + return switch (variant) { + AppLoadingButtonVariant.secondary => shad.SecondaryButton( + onPressed: effectiveOnPressed, + leading: leading, + alignment: alignment, + child: effectiveChild, + ), + AppLoadingButtonVariant.primary => AppPrimaryButton( + onPressed: effectiveOnPressed, + leading: leading, + alignment: alignment, + child: effectiveChild, + ), + AppLoadingButtonVariant.brand => shad.ButtonStyleOverride( + decoration: (context, states, value) { + final disabled = states.contains(WidgetState.disabled); + final color = loading + ? palette.brand + : disabled + ? palette.brand.withValues(alpha: 0.38) + : states.contains(WidgetState.pressed) + ? Color.alphaBlend(palette.brandForeground.withValues(alpha: 0.12), palette.brand) + : states.contains(WidgetState.hovered) + ? Color.alphaBlend(palette.brandForeground.withValues(alpha: 0.08), palette.brand) + : palette.brand; + return BoxDecoration(color: color, borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius)); + }, + textStyle: (context, states, value) => value.copyWith( + color: states.contains(WidgetState.disabled) && !loading + ? palette.brandForeground.withValues(alpha: 0.55) + : palette.brandForeground, + ), + iconTheme: (context, states, value) => value.copyWith( + color: states.contains(WidgetState.disabled) && !loading + ? palette.brandForeground.withValues(alpha: 0.55) + : palette.brandForeground, + ), + child: shad.SecondaryButton( + onPressed: effectiveOnPressed, + leading: leading, + alignment: alignment, + child: effectiveChild, + ), + ), + }; + } +} + +class _LoadingIcon extends StatelessWidget { + const _LoadingIcon({required this.variant}); + + final AppLoadingButtonVariant variant; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final foreground = switch (variant) { + AppLoadingButtonVariant.secondary => palette.textMuted, + AppLoadingButtonVariant.primary => palette.primaryActionForeground, + AppLoadingButtonVariant.brand => palette.brandForeground, + }; + final background = switch (variant) { + AppLoadingButtonVariant.secondary => palette.border, + AppLoadingButtonVariant.primary || AppLoadingButtonVariant.brand => foreground.withValues(alpha: 0.24), + }; + return Center( + child: SizedBox.square( + dimension: 14, + child: shad.CircularProgressIndicator(size: 14, color: foreground, backgroundColor: background), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart new file mode 100644 index 00000000..1cd710a9 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../core/utils/breakpoints.dart'; +import '../services/download_directory_picker.dart'; +import 'app_tooltip.dart'; + +enum AppPathPickerButtonStyle { ghost, outline } + +class AppPathPickerField extends StatefulWidget { + const AppPathPickerField({ + super.key, + required this.controller, + required this.onPick, + this.fieldKey, + this.pickerKey, + this.hintText, + this.desktopWidth, + this.filled, + this.border, + this.borderRadius, + this.padding, + this.onChanged, + this.pickerButtonStyle = AppPathPickerButtonStyle.ghost, + }) : platformDownloadDirectory = false, + onDirectoryPicked = null, + allowAndroidEditing = false; + + const AppPathPickerField.downloadDirectory({ + super.key, + required this.controller, + this.fieldKey, + this.pickerKey, + this.hintText, + this.desktopWidth, + this.onDirectoryPicked, + this.allowAndroidEditing = false, + this.filled, + this.border, + this.borderRadius, + this.padding, + this.onChanged, + this.pickerButtonStyle = AppPathPickerButtonStyle.outline, + }) : onPick = null, + platformDownloadDirectory = true; + + final TextEditingController controller; + final VoidCallback? onPick; + final Key? fieldKey; + final Key? pickerKey; + final String? hintText; + final double? desktopWidth; + final bool? filled; + final Border? border; + final BorderRadiusGeometry? borderRadius; + final EdgeInsetsGeometry? padding; + final ValueChanged? onChanged; + final AppPathPickerButtonStyle pickerButtonStyle; + final bool platformDownloadDirectory; + final ValueChanged? onDirectoryPicked; + final bool allowAndroidEditing; + + @override + State createState() => _AppPathPickerFieldState(); +} + +class _AppPathPickerFieldState extends State { + late String _tooltipPath = widget.controller.text.trim(); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_syncTooltipPath); + } + + @override + void didUpdateWidget(covariant AppPathPickerField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_syncTooltipPath); + widget.controller.addListener(_syncTooltipPath); + _tooltipPath = widget.controller.text.trim(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_syncTooltipPath); + super.dispose(); + } + + void _syncTooltipPath([String? value]) { + final path = (value ?? widget.controller.text).trim(); + if (path == _tooltipPath || !mounted) return; + setState(() => _tooltipPath = path); + } + + Future _pickPlatformDirectory() async { + final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim()); + if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return; + widget.controller.text = path; + _syncTooltipPath(path); + widget.onDirectoryPicked?.call(path); + } + + @override + Widget build(BuildContext context) { + final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; + final onPick = widget.platformDownloadDirectory + ? (DownloadDirectoryPicker.canPick ? _pickPlatformDirectory : null) + : widget.onPick; + return SizedBox( + width: desktop ? widget.desktopWidth : double.infinity, + child: Row( + children: [ + Expanded( + child: MouseRegion( + onEnter: (_) => _syncTooltipPath(), + child: AppTooltip( + message: _tooltipPath, + child: shad.TextField( + key: widget.fieldKey, + controller: widget.controller, + hintText: widget.hintText, + readOnly: + widget.platformDownloadDirectory && + !DownloadDirectoryPicker.canEditManually(allowAndroidEditing: widget.allowAndroidEditing), + filled: widget.filled, + border: widget.border, + borderRadius: widget.borderRadius, + padding: widget.padding, + onChanged: widget.onChanged, + features: [ + if (onPick != null && widget.pickerButtonStyle == AppPathPickerButtonStyle.outline) + _PathPickerSuffixFeature(pickerKey: widget.pickerKey, onPressed: onPick), + ], + ), + ), + ), + ), + if (onPick != null && widget.pickerButtonStyle == AppPathPickerButtonStyle.ghost) ...[ + const SizedBox(width: 2), + shad.GhostButton( + key: widget.pickerKey, + density: shad.ButtonDensity.icon, + onPressed: onPick, + child: const Icon(Icons.folder_open_outlined, size: 17), + ), + ], + ], + ), + ); + } +} + +class _PathPickerSuffixFeature extends shad.InputFeature { + const _PathPickerSuffixFeature({required this.pickerKey, required this.onPressed}) : super(skipFocusTraversal: false); + + final Key? pickerKey; + final VoidCallback onPressed; + + @override + shad.InputFeatureState createState() => _PathPickerSuffixFeatureState(); +} + +class _PathPickerSuffixFeatureState extends shad.InputFeatureState<_PathPickerSuffixFeature> { + @override + Iterable buildSuffix() sync* { + yield shad.IconButton.outline( + key: feature.pickerKey, + onPressed: feature.onPressed, + icon: const Icon(Icons.folder_open_outlined, size: 17), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_primary_button.dart b/ui/flutter/lib/shared/widgets/app_primary_button.dart new file mode 100644 index 00000000..95f0127b --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_primary_button.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../theme/app_design_tokens.dart'; +import '../theme/app_palette.dart'; + +class AppPrimaryButton extends StatelessWidget { + const AppPrimaryButton({ + super.key, + required this.child, + this.onPressed, + this.leading, + this.trailing, + this.alignment, + this.size = shad.ButtonSize.normal, + this.density = shad.ButtonDensity.normal, + this.shape = shad.ButtonShape.rectangle, + this.disableTransition = false, + }); + + final Widget child; + final VoidCallback? onPressed; + final Widget? leading; + final Widget? trailing; + final AlignmentGeometry? alignment; + final shad.ButtonSize size; + final shad.ButtonDensity density; + final shad.ButtonShape shape; + final bool disableTransition; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + + return shad.ButtonStyleOverride( + decoration: (context, states, value) { + return BoxDecoration( + color: _resolveBackground(palette, states), + borderRadius: shape == shad.ButtonShape.circle ? null : BorderRadius.circular(AppDesignTokens.controlRadius), + shape: shape == shad.ButtonShape.circle ? BoxShape.circle : BoxShape.rectangle, + ); + }, + textStyle: (context, states, value) { + return value.copyWith(color: _resolveForeground(palette, states)); + }, + iconTheme: (context, states, value) { + return value.copyWith(color: _resolveForeground(palette, states)); + }, + child: shad.PrimaryButton( + onPressed: onPressed, + leading: leading, + trailing: trailing, + alignment: alignment, + size: size, + density: density, + shape: shape, + disableTransition: disableTransition, + child: child, + ), + ); + } + + Color _resolveBackground(AppPalette palette, Set states) { + if (states.contains(WidgetState.disabled)) { + return palette.primaryActionBg.withValues(alpha: 0.38); + } + if (states.contains(WidgetState.pressed)) { + return Color.alphaBlend(palette.primaryActionForeground.withValues(alpha: 0.14), palette.primaryActionBg); + } + if (states.contains(WidgetState.hovered)) { + return palette.primaryActionHoverBg; + } + return palette.primaryActionBg; + } + + Color _resolveForeground(AppPalette palette, Set states) { + if (states.contains(WidgetState.disabled)) { + return palette.primaryActionForeground.withValues(alpha: 0.55); + } + return palette.primaryActionForeground; + } +} diff --git a/ui/flutter/lib/shared/widgets/app_toast.dart b/ui/flutter/lib/shared/widgets/app_toast.dart new file mode 100644 index 00000000..eb896516 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_toast.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../theme/app_palette.dart'; + +enum AppToastType { info, success, error } + +/// Shows the application's shared toast presentation. +/// +/// Toast width is controlled by the loose global [shad.ToastTheme] constraint: +/// short messages stay compact while long messages wrap before becoming wider +/// than the configured maximum. Errors and normal notices use the same layout. +void showAppToast(BuildContext context, String message, {AppToastType type = AppToastType.info}) { + final palette = AppPalette.of(context); + final borderColor = switch (type) { + AppToastType.error => palette.error.withValues(alpha: 0.55), + AppToastType.success || AppToastType.info => palette.border, + }; + shad.showToast( + context: context, + location: shad.ToastLocation.topCenter, + builder: (context, overlay) => Align( + alignment: Alignment.topCenter, + widthFactor: 1, + heightFactor: 1, + child: Container( + key: const ValueKey('app-toast-content'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), + ), + ), + ); +} diff --git a/ui/flutter/lib/shared/widgets/app_tooltip.dart b/ui/flutter/lib/shared/widgets/app_tooltip.dart new file mode 100644 index 00000000..339e7f95 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_tooltip.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart' show Tooltip; +import 'package:flutter/widgets.dart'; + +/// The single product tooltip primitive. +/// +/// Flutter's tooltip is used intentionally because the shadcn tooltip does not +/// currently inherit Gopeed's mixed Shadcn/Material theme consistently. +class AppTooltip extends StatelessWidget { + const AppTooltip({super.key, required this.message, required this.child}); + + final String message; + final Widget child; + + @override + Widget build(BuildContext context) { + return Tooltip(message: message, child: child); + } +} diff --git a/ui/flutter/lib/shared/widgets/detail/app_detail_surface.dart b/ui/flutter/lib/shared/widgets/detail/app_detail_surface.dart new file mode 100644 index 00000000..d5b973be --- /dev/null +++ b/ui/flutter/lib/shared/widgets/detail/app_detail_surface.dart @@ -0,0 +1,2 @@ +export 'app_detail_surface_desktop.dart'; +export 'app_detail_surface_mobile.dart'; diff --git a/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart new file mode 100644 index 00000000..75f722f5 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_desktop.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../l10n/l10n.dart'; +import '../../theme/app_design_tokens.dart'; +import '../../theme/app_palette.dart'; +import '../app_tooltip.dart'; + +class AppDetailDrawer extends StatelessWidget { + const AppDetailDrawer({ + super.key, + required this.open, + required this.title, + required this.onClose, + required this.child, + this.drawerKey, + this.width, + }); + + final bool open; + final String title; + final VoidCallback onClose; + final Widget child; + final Key? drawerKey; + final double? width; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final light = shad.Theme.of(context).brightness == Brightness.light; + final drawerWidth = width ?? AppDesignTokens.taskDetailsDrawerWidth(MediaQuery.sizeOf(context).width); + + return IgnorePointer( + ignoring: !open, + child: Stack( + children: [ + AnimatedOpacity( + opacity: open ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: GestureDetector( + onTap: onClose, + child: ColoredBox(color: Colors.black.withValues(alpha: 0.35), child: const SizedBox.expand()), + ), + ), + Align( + alignment: Alignment.centerRight, + child: AnimatedSlide( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + offset: open ? Offset.zero : const Offset(1, 0), + child: Container( + key: drawerKey, + width: drawerWidth, + decoration: BoxDecoration( + color: light ? palette.cardBg : palette.bg, + border: Border( + left: BorderSide(color: light ? palette.headerDivider : palette.border), + top: BorderSide(color: palette.headerDivider), + ), + boxShadow: light + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 24, + offset: const Offset(-8, 0), + ), + ] + : null, + ), + child: Column( + children: [ + SizedBox( + key: const ValueKey('app-detail-drawer-header'), + height: AppDesignTokens.contentHeaderHeight, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.headerDivider)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 24, end: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + AppTooltip( + message: context.l10n.close, + child: SizedBox.square( + dimension: 28, + child: shad.IconButton.ghost( + key: const ValueKey('app-detail-drawer-close-button'), + size: shad.ButtonSize.xSmall, + onPressed: onClose, + icon: Icon(Icons.close, size: 18, color: palette.textSecondary), + ), + ), + ), + ], + ), + ), + ), + ), + Expanded(child: open ? child : const SizedBox.shrink()), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart new file mode 100644 index 00000000..71f340ff --- /dev/null +++ b/ui/flutter/lib/shared/widgets/detail/app_detail_surface_mobile.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../l10n/l10n.dart'; +import '../../theme/app_palette.dart'; +import '../app_tooltip.dart'; + +class AppDetailPage extends StatelessWidget { + const AppDetailPage({ + super.key, + required this.title, + required this.onBack, + required this.child, + this.backgroundColor, + }); + + final String title; + final VoidCallback onBack; + final Widget child; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final background = backgroundColor ?? palette.bg; + + return shad.Scaffold( + backgroundColor: background, + child: ColoredBox( + color: background, + child: Column( + children: [ + SafeArea( + bottom: false, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: Row( + children: [ + SizedBox( + width: 44, + child: AppTooltip( + message: context.l10n.back, + child: shad.IconButton.ghost( + key: const ValueKey('app-detail-back-button'), + onPressed: onBack, + icon: Icon(Icons.arrow_back, size: 20, color: palette.textPrimary), + ), + ), + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 44), + ], + ), + ), + ), + Expanded(child: child), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/file_tree_view.dart b/ui/flutter/lib/shared/widgets/file_tree_view.dart new file mode 100644 index 00000000..efab4ddb --- /dev/null +++ b/ui/flutter/lib/shared/widgets/file_tree_view.dart @@ -0,0 +1,1085 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; + +import '../theme/app_palette.dart'; +import '../../l10n/l10n.dart'; +import '../../util/util.dart'; +import 'virtual_tree_view.dart'; + +class FileTreeItem { + const FileTreeItem({ + required this.key, + required this.path, + required this.name, + required this.size, + required this.data, + }); + + final String key; + final String path; + final String name; + final int size; + final T data; +} + +class FileTreeNode { + const FileTreeNode({ + required this.key, + required this.label, + required this.leafItems, + required this.size, + required this.originalIndex, + this.item, + }); + + final String key; + final String label; + final List> leafItems; + final int size; + final int originalIndex; + final FileTreeItem? item; + + bool get isFolder => item == null; + + @override + bool operator ==(Object other) => other is FileTreeNode && other.key == key; + + @override + int get hashCode => key.hashCode; +} + +typedef FileTreeNodeWidgetBuilder = Widget Function(BuildContext context, FileTreeNode node); +typedef FileTreeNodeIconBuilder = IconData Function(FileTreeNode node); + +class FileTreeView extends StatefulWidget { + const FileTreeView({ + super.key, + required this.items, + this.headerLeading, + this.leadingBuilder, + this.iconBuilder, + this.trailingBuilder, + this.onNodePressed, + this.keyPrefix = 'file-tree', + this.emptyLabel, + this.showSizeColumn = true, + this.trailingHeader, + this.rowHeight = _treeRowHeight, + this.contentTextStyle, + this.iconSize = 18, + }); + + final List> items; + final Widget? headerLeading; + final FileTreeNodeWidgetBuilder? leadingBuilder; + final FileTreeNodeIconBuilder? iconBuilder; + final FileTreeNodeWidgetBuilder? trailingBuilder; + final ValueChanged>? onNodePressed; + final String keyPrefix; + final String? emptyLabel; + final bool showSizeColumn; + final Widget? trailingHeader; + final double rowHeight; + final TextStyle? contentTextStyle; + final double iconSize; + + @override + State> createState() => _FileTreeViewState(); +} + +class _FileTreeViewState extends State> with SingleTickerProviderStateMixin { + late List>> _nodes; + late final AnimationController _transitionController; + late final Animation _reveal; + final _scrollController = ScrollController(); + final _horizontalScrollController = ScrollController(); + final Map _labelWidthCache = {}; + TextStyle? _labelMeasurementStyle; + TextDirection? _labelMeasurementDirection; + TextScaler? _labelMeasurementScaler; + List>>? _pendingCollapsedNodes; + Set _transitioningNodeKeys = const {}; + var _transitioning = false; + var _allFoldersExpanded = false; + _FileTreeSortColumn? _sortColumn; + _FileTreeSortDirection _sortDirection = _FileTreeSortDirection.none; + + @override + void initState() { + super.initState(); + _nodes = _buildTree(widget.items); + _allFoldersExpanded = _foldersAreExpanded(_nodes); + _transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1) + ..addStatusListener(_onTransitionStatus); + _reveal = CurvedAnimation(parent: _transitionController, curve: Curves.easeOutCubic); + } + + @override + void didUpdateWidget(covariant FileTreeView oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(widget.items, oldWidget.items)) return; + + final expandedKeys = _expandedFolderKeys(_nodes); + var nodes = _restoreExpandedFolders(_buildTree(widget.items), expandedKeys); + nodes = _sortNodes(nodes, _sortColumn, _sortDirection); + _nodes = nodes; + _allFoldersExpanded = _foldersAreExpanded(nodes); + _pendingCollapsedNodes = null; + _transitioningNodeKeys = const {}; + _transitioning = false; + _transitionController.value = 1; + } + + @override + void dispose() { + _transitionController.removeStatusListener(_onTransitionStatus); + _transitionController.dispose(); + _scrollController.dispose(); + _horizontalScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final treeItemInset = theme.density.baseGap * theme.scaling; + final hasTrailingColumn = widget.trailingBuilder != null || widget.trailingHeader != null || widget.showSizeColumn; + final trailingColumnWidth = hasTrailingColumn + ? (widget.trailingBuilder == null ? _treeSizeColumnWidth : _treeCustomTrailingColumnWidth) + : 0.0; + final labelStyle = DefaultTextStyle.of(context).style.merge(widget.contentTextStyle); + final textDirection = Directionality.of(context); + final textScaler = MediaQuery.textScalerOf(context); + if (_labelMeasurementStyle != labelStyle || + _labelMeasurementDirection != textDirection || + _labelMeasurementScaler != textScaler) { + _labelWidthCache.clear(); + _labelMeasurementStyle = labelStyle; + _labelMeasurementDirection = textDirection; + _labelMeasurementScaler = textScaler; + } + double measureLabel(String label) => _labelWidthCache.putIfAbsent( + label, + () => _measureLabelWidth(label, style: labelStyle, textDirection: textDirection, textScaler: textScaler), + ); + final nameContentWidth = _widestLabelWidth(widget.items, measureLabel); + final fixedNameChrome = + _treeNodeExpandChrome + widget.iconSize + 4 + (widget.leadingBuilder == null ? 0 : _treeLeadingChrome); + + return LayoutBuilder( + builder: (context, constraints) { + final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth; + final horizontalOverflow = _horizontalOverflow( + widget.items, + baseNameViewport: + treeWidth - _treeHorizontalPadding - trailingColumnWidth - _treeTrailingGap - fixedNameChrome, + depthIndent: treeItemInset * 3, + measureLabel: measureLabel, + ); + + return OutlinedContainer( + child: Column( + children: [ + _FileTreeToolbar( + keyPrefix: widget.keyPrefix, + headerLeading: widget.headerLeading, + allExpanded: _allFoldersExpanded, + nameSortDirection: _sortColumn == _FileTreeSortColumn.name + ? _sortDirection + : _FileTreeSortDirection.none, + sizeSortDirection: _sortColumn == _FileTreeSortColumn.size + ? _sortDirection + : _FileTreeSortDirection.none, + showSizeColumn: widget.showSizeColumn, + trailingHeader: widget.trailingHeader, + trailingColumnWidth: trailingColumnWidth, + onNameSort: () => _toggleSort(_FileTreeSortColumn.name), + onSizeSort: () => _toggleSort(_FileTreeSortColumn.size), + onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll, + ), + Expanded( + child: _nodes.isEmpty + ? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles) + : VirtualTreeView>( + nodes: _nodes, + controller: _scrollController, + branchLine: BranchLine.path, + rowHeight: widget.rowHeight, + padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4), + itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5), + itemBuilder: (context, entry) => _buildTreeItem( + context, + entry, + nameContentWidth: nameContentWidth, + trailingColumnWidth: trailingColumnWidth, + hasTrailingColumn: hasTrailingColumn, + horizontalScrollEnabled: horizontalOverflow > 0.5, + contentTextStyle: labelStyle, + ), + transitionBuilder: _buildTreeTransition, + ), + ), + if (_nodes.isNotEmpty && horizontalOverflow > 0.5) + _FileNameHorizontalScrollbar( + keyPrefix: widget.keyPrefix, + controller: _horizontalScrollController, + overflow: horizontalOverflow, + trailingColumnWidth: trailingColumnWidth, + ), + ], + ), + ); + }, + ); + } + + Widget _buildTreeItem( + BuildContext context, + VirtualTreeEntry> entry, { + required double nameContentWidth, + required double trailingColumnWidth, + required bool hasTrailingColumn, + required bool horizontalScrollEnabled, + required TextStyle contentTextStyle, + }) { + final palette = AppPalette.of(context); + final node = entry.node; + final data = node.data; + final pendingNode = _pendingCollapsedNodes == null ? null : _findTreeNode(_pendingCollapsedNodes!, data.key); + final visuallyExpanded = pendingNode?.expanded ?? node.expanded; + final leading = widget.leadingBuilder?.call(context, data); + final trailing = widget.trailingBuilder?.call(context, data); + final trailingContent = + trailing ?? + (data.size > 0 + ? Text(Util.fmtByte(data.size), style: contentTextStyle.copyWith(color: palette.textSecondary)) + : const SizedBox.shrink()); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _FileTreeNodeExpandButton( + key: ValueKey('${widget.keyPrefix}-node-expand-${data.key}'), + expandable: node.children.isNotEmpty, + expanded: visuallyExpanded, + rowHeight: widget.rowHeight, + onPressed: () => _toggleFolder(data.key), + ), + const SizedBox(width: _treeExpandLeadingGap), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onNodePressed == null ? null : () => widget.onNodePressed!(data), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (leading != null) ...[leading, const SizedBox(width: 8)], + Icon( + widget.iconBuilder?.call(data) ?? + (data.isFolder + ? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2) + : _fileIcon(data.label)), + size: widget.iconSize, + color: palette.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: ClipRect( + child: AnimatedBuilder( + animation: _horizontalScrollController, + builder: (context, child) { + final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients + ? _horizontalScrollController.offset + : 0.0; + return Transform.translate(offset: Offset(-offset, 0), child: child); + }, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: 0, + maxWidth: double.infinity, + child: SizedBox( + width: nameContentWidth, + child: Text( + data.label, + maxLines: 1, + softWrap: false, + style: contentTextStyle.copyWith(color: palette.textPrimary), + ), + ), + ), + ), + ), + ), + const SizedBox(width: 4), + if (hasTrailingColumn) + SizedBox( + key: ValueKey('${widget.keyPrefix}-trailing-${data.key}'), + width: trailingColumnWidth, + child: Align(alignment: Alignment.centerRight, child: trailingContent), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildTreeTransition(BuildContext context, VirtualTreeEntry> entry, Widget row) { + if (_transitioningNodeKeys.contains(entry.node.data.key)) { + return SizeTransition( + sizeFactor: _reveal, + axisAlignment: -1, + child: FadeTransition(opacity: _reveal, child: row), + ); + } + return row; + } + + void _onTransitionStatus(AnimationStatus status) { + if (!mounted) return; + if (status == AnimationStatus.completed && _pendingCollapsedNodes == null && _transitioning) { + setState(() { + _transitioningNodeKeys = const {}; + _transitioning = false; + }); + return; + } + if (status == AnimationStatus.dismissed && _pendingCollapsedNodes != null) { + final nodes = _pendingCollapsedNodes!; + setState(() { + _nodes = nodes; + _allFoldersExpanded = _foldersAreExpanded(nodes); + _pendingCollapsedNodes = null; + _transitioningNodeKeys = const {}; + _transitioning = false; + }); + _transitionController.value = 1; + } + } + + void _toggleFolder(String key) { + final node = _findTreeNode(_nodes, key); + if (node == null) return; + _transitionToTree(node.expanded ? _nodes.collapseNode(node) : _nodes.expandNode(node)); + } + + void _expandAll() => _transitionToTree(_nodes.expandAll()); + + void _collapseAll() => _transitionToTree(_nodes.collapseAll()); + + void _transitionToTree(List>> nodes) { + if (_transitioning) return; + final currentKeyOrder = _visibleNodeKeysInOrder(_nodes); + final nextKeyOrder = _visibleNodeKeysInOrder(nodes); + final currentKeys = currentKeyOrder.toSet(); + final nextKeys = nextKeyOrder.toSet(); + final enteringKeys = nextKeys.difference(currentKeys); + final leavingKeys = currentKeys.difference(nextKeys); + + if (enteringKeys.isNotEmpty) { + setState(() { + _nodes = nodes; + _allFoldersExpanded = _foldersAreExpanded(nodes); + _transitioningNodeKeys = _viewportTransitionKeys(nextKeyOrder, enteringKeys); + _transitioning = true; + }); + _transitionController.forward(from: 0); + return; + } + if (leavingKeys.isNotEmpty) { + setState(() { + _pendingCollapsedNodes = nodes; + _transitioningNodeKeys = _viewportTransitionKeys(currentKeyOrder, leavingKeys); + _transitioning = true; + }); + _transitionController.reverse(from: 1); + return; + } + setState(() { + _nodes = nodes; + _allFoldersExpanded = _foldersAreExpanded(nodes); + }); + } + + Set _viewportTransitionKeys(List keyOrder, Set changedKeys) { + if (changedKeys.isEmpty) return const {}; + var firstIndex = 0; + var rowCount = _fallbackAnimatedRows; + if (_scrollController.hasClients) { + final position = _scrollController.position; + firstIndex = (position.pixels / widget.rowHeight).floor() - _treeAnimationOverscanRows; + rowCount = (position.viewportDimension / widget.rowHeight).ceil() + (_treeAnimationOverscanRows * 2); + } + firstIndex = firstIndex.clamp(0, keyOrder.length); + final endIndex = (firstIndex + rowCount).clamp(firstIndex, keyOrder.length); + return keyOrder.sublist(firstIndex, endIndex).where(changedKeys.contains).toSet(); + } + + void _toggleSort(_FileTreeSortColumn column) { + setState(() { + if (_sortColumn != column) { + _sortColumn = column; + _sortDirection = _FileTreeSortDirection.asc; + } else { + _sortDirection = switch (_sortDirection) { + _FileTreeSortDirection.none => _FileTreeSortDirection.asc, + _FileTreeSortDirection.asc => _FileTreeSortDirection.desc, + _FileTreeSortDirection.desc => _FileTreeSortDirection.none, + }; + if (_sortDirection == _FileTreeSortDirection.none) _sortColumn = null; + } + _nodes = _sortNodes(_nodes, _sortColumn, _sortDirection); + }); + } +} + +class _FileTreeToolbar extends StatelessWidget { + const _FileTreeToolbar({ + required this.keyPrefix, + required this.headerLeading, + required this.allExpanded, + required this.nameSortDirection, + required this.sizeSortDirection, + required this.showSizeColumn, + required this.trailingHeader, + required this.trailingColumnWidth, + required this.onNameSort, + required this.onSizeSort, + required this.onToggleExpanded, + }); + + final String keyPrefix; + final Widget? headerLeading; + final bool allExpanded; + final _FileTreeSortDirection nameSortDirection; + final _FileTreeSortDirection sizeSortDirection; + final bool showSizeColumn; + final Widget? trailingHeader; + final double trailingColumnWidth; + final VoidCallback onNameSort; + final VoidCallback onSizeSort; + final VoidCallback onToggleExpanded; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), + child: SizedBox( + height: _treeHeaderControlHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _FileTreeExpandToggle(keyPrefix: keyPrefix, expanded: allExpanded, onPressed: onToggleExpanded), + const SizedBox(width: _treeExpandLeadingGap), + if (headerLeading != null) ...[headerLeading!, const SizedBox(width: 8)], + _FileTreeSortButton( + keyPrefix: keyPrefix, + sortKey: 'name', + label: context.l10n.name, + direction: nameSortDirection, + onPressed: onNameSort, + ), + const Spacer(), + if (trailingColumnWidth > 0) + SizedBox( + width: trailingColumnWidth, + child: Align( + alignment: Alignment.centerRight, + child: trailingHeader != null + ? trailingHeader! + : showSizeColumn + ? _FileTreeSortButton( + keyPrefix: keyPrefix, + sortKey: 'size', + label: context.l10n.size, + direction: sizeSortDirection, + onPressed: onSizeSort, + ) + : const SizedBox.shrink(), + ), + ), + ], + ), + ), + ); + } +} + +class _FileNameHorizontalScrollbar extends StatelessWidget { + const _FileNameHorizontalScrollbar({ + required this.keyPrefix, + required this.controller, + required this.overflow, + required this.trailingColumnWidth, + }); + + final String keyPrefix; + final ScrollController controller; + final double overflow; + final double trailingColumnWidth; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: _treeHorizontalScrollbarHeight, + child: LayoutBuilder( + builder: (context, constraints) { + final viewportWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth; + return Scrollbar( + key: ValueKey('$keyPrefix-horizontal-scrollbar'), + controller: controller, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + thickness: 6, + scrollbarOrientation: ScrollbarOrientation.bottom, + child: SingleChildScrollView( + key: ValueKey('$keyPrefix-horizontal-scroll'), + controller: controller, + scrollDirection: Axis.horizontal, + child: SizedBox(width: viewportWidth + overflow, height: 1), + ), + ); + }, + ), + ), + ), + if (trailingColumnWidth > 0) ...[ + const SizedBox(width: _treeTrailingGap), + SizedBox(width: trailingColumnWidth), + ], + ], + ), + ); + } +} + +class _FileTreeExpandToggle extends StatelessWidget { + const _FileTreeExpandToggle({required this.keyPrefix, required this.expanded, required this.onPressed}); + + final String keyPrefix; + final bool expanded; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return SizedBox( + key: ValueKey('$keyPrefix-expand-toggle'), + width: _treeExpandButtonWidth, + height: _treeHeaderControlHeight, + child: GhostButton( + density: ButtonDensity.compact, + onPressed: onPressed, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _DirectionChevron(key: ValueKey('$keyPrefix-expand-top'), up: !expanded, color: palette.textPrimary), + _DirectionChevron(key: ValueKey('$keyPrefix-expand-bottom'), up: expanded, color: palette.textPrimary), + ], + ), + ), + ), + ); + } +} + +class _FileTreeNodeExpandButton extends StatelessWidget { + const _FileTreeNodeExpandButton({ + super.key, + required this.expandable, + required this.expanded, + required this.rowHeight, + required this.onPressed, + }); + + final bool expandable; + final bool expanded; + final double rowHeight; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + if (!expandable) { + return SizedBox(width: _treeExpandButtonWidth, height: rowHeight); + } + final palette = AppPalette.of(context); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: SizedBox( + width: _treeExpandButtonWidth, + height: rowHeight, + child: Center( + child: AnimatedRotation( + turns: expanded ? 0.25 : 0, + duration: _treeTransitionDuration, + curve: Curves.easeOutCubic, + child: Icon(BootstrapIcons.caretRightFill, size: 10, color: palette.textSecondary), + ), + ), + ), + ); + } +} + +class _DirectionChevron extends StatelessWidget { + const _DirectionChevron({super.key, required this.up, required this.color}); + + final bool up; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 14, + height: 8, + child: CustomPaint( + painter: _DirectionChevronPainter(up: up, color: color), + ), + ); + } +} + +class _DirectionChevronPainter extends CustomPainter { + const _DirectionChevronPainter({required this.up, required this.color}); + + final bool up; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final path = Path(); + if (up) { + path + ..moveTo(2.5, 5.75) + ..lineTo(size.width / 2, 1.75) + ..lineTo(size.width - 2.5, 5.75); + } else { + path + ..moveTo(2.5, 2.25) + ..lineTo(size.width / 2, 6.25) + ..lineTo(size.width - 2.5, 2.25); + } + canvas.drawPath( + path, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(covariant _DirectionChevronPainter oldDelegate) { + return oldDelegate.up != up || oldDelegate.color != color; + } +} + +class _FileTreeSortButton extends StatelessWidget { + const _FileTreeSortButton({ + required this.keyPrefix, + required this.sortKey, + required this.label, + required this.direction, + required this.onPressed, + }); + + final String keyPrefix; + final String sortKey; + final String label; + final _FileTreeSortDirection direction; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return GhostButton( + density: ButtonDensity.compact, + onPressed: onPressed, + child: SizedBox( + height: _treeHeaderControlHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(label, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 2), + SizedBox( + width: 14, + height: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SortTriangle( + key: ValueKey('$keyPrefix-sort-$sortKey-asc'), + up: true, + active: direction == _FileTreeSortDirection.asc, + palette: palette, + ), + _SortTriangle( + key: ValueKey('$keyPrefix-sort-$sortKey-desc'), + up: false, + active: direction == _FileTreeSortDirection.desc, + palette: palette, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SortTriangle extends StatelessWidget { + const _SortTriangle({super.key, required this.up, required this.active, required this.palette}); + + final bool up; + final bool active; + final AppPalette palette; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(color: active ? palette.itemActiveBg : null, borderRadius: BorderRadius.circular(2)), + child: SizedBox( + width: 14, + height: 8, + child: CustomPaint( + painter: _SortTrianglePainter(up: up, color: active ? palette.textPrimary : palette.textMuted), + ), + ), + ); + } +} + +class _SortTrianglePainter extends CustomPainter { + const _SortTrianglePainter({required this.up, required this.color}); + + final bool up; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerX = size.width / 2; + const horizontalInset = 2.5; + const verticalInset = 1.75; + final left = horizontalInset; + final right = size.width - horizontalInset; + final top = verticalInset; + final bottom = size.height - verticalInset; + final path = Path(); + if (up) { + path + ..moveTo(centerX, top) + ..lineTo(right, bottom) + ..lineTo(left, bottom); + } else { + path + ..moveTo(left, top) + ..lineTo(right, top) + ..lineTo(centerX, bottom); + } + canvas.drawPath(path..close(), Paint()..color = color); + } + + @override + bool shouldRepaint(covariant _SortTrianglePainter oldDelegate) { + return oldDelegate.up != up || oldDelegate.color != color; + } +} + +class _FileTreeEmptyState extends StatelessWidget { + const _FileTreeEmptyState({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Center( + child: Text( + label, + style: TextStyle(color: palette.textMuted, fontSize: 13, fontWeight: FontWeight.w600), + ), + ); + } +} + +class _BuildNode { + _BuildNode({required this.key, required this.label, required this.originalIndex, this.item}); + + final String key; + final String label; + final int originalIndex; + final FileTreeItem? item; + final List<_BuildNode> children = []; + + int get size => item?.size ?? children.fold(0, (sum, child) => sum + child.size); + + TreeItemNode> toTreeItem() { + final childItems = children.map((child) => child.toTreeItem()).toList(growable: false); + final leaves = item == null ? [for (final child in childItems) ...child.data.leafItems] : >[item!]; + return TreeItemNode( + data: FileTreeNode( + key: key, + label: label, + leafItems: List.unmodifiable(leaves), + size: size, + originalIndex: originalIndex, + item: item, + ), + expanded: true, + children: childItems, + ); + } +} + +List>> _buildTree(List> items) { + final root = <_BuildNode>[]; + final folders = >{}; + var originalIndex = 0; + + for (final item in items) { + final parts = item.path.split(RegExp(r'[/\\]+')).where((part) => part.trim().isNotEmpty).toList(); + var currentPath = ''; + _BuildNode? parent; + + for (final part in parts) { + currentPath = currentPath.isEmpty ? part : '$currentPath/$part'; + final folder = folders.putIfAbsent(currentPath, () { + final node = _BuildNode(key: 'folder:$currentPath', label: part, originalIndex: originalIndex++); + if (parent == null) { + root.add(node); + } else { + parent.children.add(node); + } + return node; + }); + parent = folder; + } + + final fileNode = _BuildNode( + key: 'file:${item.key}', + label: item.name, + originalIndex: originalIndex++, + item: item, + ); + if (parent == null) { + root.add(fileNode); + } else { + parent.children.add(fileNode); + } + } + + return root.map((node) => node.toTreeItem()).toList(growable: false); +} + +List _visibleNodeKeysInOrder(List>> nodes) { + final keys = []; + + void collect(TreeNode> node) { + final item = node as TreeItemNode>; + keys.add(item.data.key); + if (!item.expanded) return; + for (final child in item.children) { + collect(child); + } + } + + for (final node in nodes) { + collect(node); + } + return keys; +} + +List>> _sortNodes( + List>> nodes, + _FileTreeSortColumn? column, + _FileTreeSortDirection direction, +) { + final sorted = nodes + .map((node) { + final item = node as TreeItemNode>; + return item.updateChildren(_sortNodes(item.children, column, direction)); + }) + .toList(growable: true); + sorted.sort((a, b) { + final left = a.data; + final right = b.data; + if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1; + if (column == null || direction == _FileTreeSortDirection.none) { + return left.originalIndex.compareTo(right.originalIndex); + } + final comparison = switch (column) { + _FileTreeSortColumn.name => left.label.toLowerCase().compareTo(right.label.toLowerCase()), + _FileTreeSortColumn.size => left.size.compareTo(right.size), + }; + if (comparison == 0) return left.originalIndex.compareTo(right.originalIndex); + return direction == _FileTreeSortDirection.asc ? comparison : -comparison; + }); + return sorted; +} + +TreeItemNode>? _findTreeNode(Iterable>> nodes, String key) { + for (final node in nodes) { + final item = node as TreeItemNode>; + if (item.data.key == key) return item; + final child = _findTreeNode(item.children, key); + if (child != null) return child; + } + return null; +} + +bool _foldersAreExpanded(Iterable>> nodes) { + var hasFolder = false; + var allExpanded = true; + + void inspect(TreeNode> node) { + final item = node as TreeItemNode>; + if (item.children.isNotEmpty) { + hasFolder = true; + allExpanded = allExpanded && item.expanded; + } + for (final child in item.children) { + inspect(child); + } + } + + for (final node in nodes) { + inspect(node); + } + return hasFolder && allExpanded; +} + +Set _expandedFolderKeys(Iterable>> nodes) { + final keys = {}; + void collect(TreeNode> node) { + final item = node as TreeItemNode>; + if (item.children.isNotEmpty && item.expanded) keys.add(item.data.key); + for (final child in item.children) { + collect(child); + } + } + + for (final node in nodes) { + collect(node); + } + return keys; +} + +List>> _restoreExpandedFolders( + List>> nodes, + Set expandedKeys, +) { + return nodes + .map((node) { + final item = node as TreeItemNode>; + final children = _restoreExpandedFolders(item.children, expandedKeys); + return item + .updateChildren(children) + .updateState(expanded: item.children.isNotEmpty && expandedKeys.contains(item.data.key)); + }) + .toList(growable: false); +} + +IconData _fileIcon(String name) { + final ext = _extensionOf(name); + if (_videoExts.contains(ext)) return BootstrapIcons.fileEarmarkPlay; + if (_audioExts.contains(ext)) return BootstrapIcons.fileEarmarkMusic; + if (_imageExts.contains(ext)) return BootstrapIcons.fileEarmarkImage; + if (_archiveExts.contains(ext)) return BootstrapIcons.fileEarmarkZip; + return BootstrapIcons.fileEarmark; +} + +String _extensionOf(String name) { + final index = name.lastIndexOf('.'); + if (index < 0 || index == name.length - 1) return ''; + return name.substring(index + 1).toLowerCase(); +} + +double _widestLabelWidth(List> items, double Function(String label) measureLabel) { + var widest = _treeMinimumNameContentWidth; + for (final item in items) { + widest = math.max(widest, measureLabel(item.name)); + for (final segment in item.path.split(RegExp(r'[/\\]+'))) { + if (segment.isNotEmpty) widest = math.max(widest, measureLabel(segment)); + } + } + return widest; +} + +double _measureLabelWidth( + String label, { + required TextStyle style, + required TextDirection textDirection, + required TextScaler textScaler, +}) { + final painter = TextPainter( + text: TextSpan(text: label, style: style), + maxLines: 1, + textDirection: textDirection, + textScaler: textScaler, + )..layout(); + return painter.width + _treeNameTextPadding; +} + +double _horizontalOverflow( + List> items, { + required double baseNameViewport, + required double depthIndent, + required double Function(String label) measureLabel, +}) { + var overflow = 0.0; + for (final item in items) { + final segments = item.path.split(RegExp(r'[/\\]+')).where((segment) => segment.isNotEmpty).toList(); + for (var depth = 0; depth < segments.length; depth++) { + final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - (depth * depthIndent)); + overflow = math.max(overflow, measureLabel(segments[depth]) - viewport); + } + final fileViewport = math.max(_treeMinimumNameViewport, baseNameViewport - (segments.length * depthIndent)); + overflow = math.max(overflow, measureLabel(item.name) - fileViewport); + } + return math.max(0, overflow); +} + +enum _FileTreeSortColumn { name, size } + +enum _FileTreeSortDirection { none, asc, desc } + +const _videoExts = {'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'}; +const _audioExts = {'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'}; +const _imageExts = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'}; +const _archiveExts = {'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'}; +const _treeExpandButtonWidth = 18.0; +const _treeExpandLeadingGap = 4.0; +const _treeContentOffset = 8.0; +const _treeHeaderControlHeight = 24.0; +const _treeRowHeight = 28.0; +const _treeSizeColumnWidth = 72.0; +const _treeCustomTrailingColumnWidth = 116.0; +const _treeTrailingGap = 4.0; +const _treeHorizontalPadding = 16.0; +const _treeNodeExpandChrome = _treeExpandButtonWidth + _treeExpandLeadingGap; +const _treeLeadingChrome = 24.0; +const _treeMinimumNameViewport = 48.0; +const _treeMinimumNameContentWidth = 48.0; +const _treeNameTextPadding = 8.0; +const _treeFallbackWidth = 600.0; +const _treeHorizontalScrollbarHeight = 12.0; +const _treeTransitionDuration = Duration(milliseconds: 150); +const _treeAnimationOverscanRows = 3; +const _fallbackAnimatedRows = 32; diff --git a/ui/flutter/lib/shared/widgets/gopeed_app_mark.dart b/ui/flutter/lib/shared/widgets/gopeed_app_mark.dart new file mode 100644 index 00000000..efa4cf10 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/gopeed_app_mark.dart @@ -0,0 +1,129 @@ +import 'package:flutter/widgets.dart'; + +import '../theme/app_palette.dart'; + +class GopeedAppMark extends StatelessWidget { + const GopeedAppMark({super.key, this.size = 24}); + + final double size; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Semantics( + label: 'Gopeed', + image: true, + child: SizedBox.square( + dimension: size, + child: CustomPaint( + painter: _GopeedAppMarkPainter( + background: palette.primaryActionBg, + foreground: palette.primaryActionForeground, + ), + ), + ), + ); + } +} + +class _GopeedAppMarkPainter extends CustomPainter { + const _GopeedAppMarkPainter({required this.background, required this.foreground}); + + final Color background; + final Color foreground; + + @override + void paint(Canvas canvas, Size size) { + final side = size.shortestSide; + final bounds = Rect.fromCenter(center: size.center(Offset.zero), width: side, height: side).deflate(side * 0.02); + final circle = Path()..addOval(bounds); + final backgroundPaint = Paint() + ..color = background + ..isAntiAlias = true; + final foregroundPaint = Paint() + ..color = foreground + ..isAntiAlias = true; + canvas.drawPath(circle, backgroundPaint); + canvas.save(); + canvas.clipPath(circle, doAntiAlias: true); + + Offset point(double x, double y) { + return Offset(bounds.left + bounds.width * x, bounds.top + bounds.height * y); + } + + final cloud = Path() + ..moveTo(point(-0.02, 0.83).dx, point(-0.02, 0.83).dy) + ..cubicTo( + point(0.04, 0.78).dx, + point(0.04, 0.78).dy, + point(0.09, 0.75).dx, + point(0.09, 0.75).dy, + point(0.15, 0.73).dx, + point(0.15, 0.73).dy, + ) + ..cubicTo( + point(0.12, 0.66).dx, + point(0.12, 0.66).dy, + point(0.13, 0.60).dx, + point(0.13, 0.60).dy, + point(0.20, 0.57).dx, + point(0.20, 0.57).dy, + ) + ..cubicTo( + point(0.27, 0.54).dx, + point(0.27, 0.54).dy, + point(0.31, 0.58).dx, + point(0.31, 0.58).dy, + point(0.35, 0.62).dx, + point(0.35, 0.62).dy, + ) + ..cubicTo( + point(0.46, 0.42).dx, + point(0.46, 0.42).dy, + point(0.66, 0.40).dx, + point(0.66, 0.40).dy, + point(0.78, 0.50).dx, + point(0.78, 0.50).dy, + ) + ..cubicTo( + point(0.86, 0.57).dx, + point(0.86, 0.57).dy, + point(0.87, 0.68).dx, + point(0.87, 0.68).dy, + point(0.86, 0.75).dx, + point(0.86, 0.75).dy, + ) + ..cubicTo( + point(0.92, 0.76).dx, + point(0.92, 0.76).dy, + point(0.98, 0.79).dx, + point(0.98, 0.79).dy, + point(1.03, 0.84).dx, + point(1.03, 0.84).dy, + ); + canvas.drawPath( + cloud, + Paint() + ..color = foreground + ..style = PaintingStyle.stroke + ..strokeWidth = side * 0.05 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..isAntiAlias = true, + ); + + final arrow = Path() + ..addRect(Rect.fromPoints(point(0.41, 0.66), point(0.59, 0.86))) + ..moveTo(point(0.31, 0.85).dx, point(0.31, 0.85).dy) + ..lineTo(point(0.69, 0.85).dx, point(0.69, 0.85).dy) + ..lineTo(point(0.50, 1.02).dx, point(0.50, 1.02).dy) + ..close(); + canvas.drawPath(arrow, foregroundPaint); + canvas.restore(); + } + + @override + bool shouldRepaint(_GopeedAppMarkPainter oldDelegate) { + return background != oldDelegate.background || foreground != oldDelegate.foreground; + } +} diff --git a/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart b/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart new file mode 100644 index 00000000..6c189a33 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../core/utils/breakpoints.dart'; +import '../theme/app_palette.dart'; +import 'secondary_navigation_pane.dart'; + +class ResponsiveMenuItem { + const ResponsiveMenuItem({required this.value, required this.label, required this.icon, this.trailing}); + + final T value; + final String label; + final IconData icon; + final String? trailing; +} + +class ResponsiveMenuLayout extends StatefulWidget { + const ResponsiveMenuLayout({ + super.key, + required this.title, + required this.items, + required this.selectedValue, + required this.onSelected, + required this.contentBuilder, + this.mobileContentTitleBuilder, + this.mobileContentVisible = false, + this.onMobileBack, + this.sidebarFooter, + this.mobileBreakpoint = Breakpoints.mobile, + }); + + final String title; + final List> items; + final T selectedValue; + final ValueChanged onSelected; + final Widget Function(BuildContext context, T selectedValue) contentBuilder; + final String Function(T selectedValue)? mobileContentTitleBuilder; + final bool mobileContentVisible; + final VoidCallback? onMobileBack; + final Widget? sidebarFooter; + final double mobileBreakpoint; + + @override + State> createState() => _ResponsiveMenuLayoutState(); +} + +class _ResponsiveMenuLayoutState extends State> { + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final mobile = width < widget.mobileBreakpoint; + if (!mobile) { + return Row( + children: [ + SecondaryNavigationPane( + title: widget.title, + items: widget.items + .map( + (item) => SecondaryNavigationPaneItem( + value: item.value, + label: item.label, + icon: item.icon, + trailing: item.trailing, + ), + ) + .toList(), + selectedValue: widget.selectedValue, + onSelected: widget.onSelected, + footer: widget.sidebarFooter, + ), + Expanded(child: widget.contentBuilder(context, widget.selectedValue)), + ], + ); + } + + if (!widget.mobileContentVisible) { + return SafeArea( + bottom: false, + child: SecondaryNavigationPane( + title: widget.title, + items: widget.items + .map( + (item) => SecondaryNavigationPaneItem( + value: item.value, + label: item.label, + icon: item.icon, + trailing: item.trailing, + ), + ) + .toList(), + selectedValue: widget.selectedValue, + onSelected: widget.onSelected, + footer: widget.sidebarFooter, + mobile: true, + showDisclosure: true, + ), + ); + } + + return SafeArea( + bottom: false, + child: Column( + children: [ + _MobileContentHeader( + title: widget.mobileContentTitleBuilder?.call(widget.selectedValue) ?? widget.title, + onBack: widget.onMobileBack ?? () {}, + ), + Expanded(child: widget.contentBuilder(context, widget.selectedValue)), + ], + ), + ); + } +} + +class _MobileContentHeader extends StatelessWidget { + const _MobileContentHeader({required this.title, required this.onBack}); + + final String title; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + key: const ValueKey('mobile-content-header'), + height: 56, + decoration: BoxDecoration( + color: palette.bg, + border: Border(bottom: BorderSide(color: palette.border)), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: onBack, + child: Icon(Icons.arrow_back, size: 18, color: palette.textPrimary), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textPrimary, fontSize: 16, fontWeight: FontWeight.w800), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart b/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart new file mode 100644 index 00000000..e25a3985 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart @@ -0,0 +1,166 @@ +import 'package:flutter/widgets.dart'; + +import '../../core/utils/breakpoints.dart'; +import '../theme/app_palette.dart'; +import 'secondary_navigation_pane.dart'; + +class ResponsiveNavigationItem { + const ResponsiveNavigationItem({required this.value, required this.label, this.icon, this.count}); + + final T value; + final String label; + final IconData? icon; + final int? count; +} + +class ResponsiveNavigationLayout extends StatelessWidget { + const ResponsiveNavigationLayout({ + super.key, + required this.items, + required this.selectedValue, + required this.onSelected, + required this.child, + required this.desktopTitle, + this.desktopFooter, + this.mobileHeader, + this.mobileBreakpoint = Breakpoints.mobile, + }); + + final List> items; + final T selectedValue; + final ValueChanged onSelected; + final Widget child; + final String desktopTitle; + final Widget? desktopFooter; + final Widget? mobileHeader; + final double mobileBreakpoint; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final mobile = width < mobileBreakpoint; + if (!mobile) { + return Row( + children: [ + SecondaryNavigationPane( + title: desktopTitle, + items: items + .map( + (item) => SecondaryNavigationPaneItem( + value: item.value, + label: item.label, + icon: item.icon, + trailing: item.count?.toString(), + ), + ) + .toList(), + selectedValue: selectedValue, + onSelected: onSelected, + footer: desktopFooter, + ), + Expanded(child: child), + ], + ); + } + + return Column( + children: [ + ?mobileHeader, + _MobileNavigationTabs( + items: items, + selectedValue: selectedValue, + onSelected: onSelected, + reserveTopSafeArea: mobileHeader == null, + ), + Expanded(child: child), + ], + ); + } +} + +class _MobileNavigationTabs extends StatelessWidget { + const _MobileNavigationTabs({ + required this.items, + required this.selectedValue, + required this.onSelected, + required this.reserveTopSafeArea, + }); + + final List> items; + final T selectedValue; + final ValueChanged onSelected; + final bool reserveTopSafeArea; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: palette.bg, + border: Border(bottom: BorderSide(color: palette.border)), + ), + child: SafeArea( + top: reserveTopSafeArea, + bottom: false, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: palette.surfaceSoft, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in items) + _MobileNavigationTab( + item: item, + active: item.value == selectedValue, + onTap: () => onSelected(item.value), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MobileNavigationTab extends StatelessWidget { + const _MobileNavigationTab({required this.item, required this.active, required this.onTap}); + + final ResponsiveNavigationItem item; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final label = item.count == null ? item.label : '${item.label} ${item.count}'; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration(color: active ? palette.cardBg : null, borderRadius: BorderRadius.circular(4)), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 12, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart b/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart new file mode 100644 index 00000000..607e3bc1 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/widgets.dart'; + +import '../../core/window/app_window_chrome.dart'; +import '../theme/app_design_tokens.dart'; +import '../theme/app_palette.dart'; + +class SecondaryNavigationPaneItem { + const SecondaryNavigationPaneItem({required this.value, required this.label, this.icon, this.trailing}); + + final T value; + final String label; + final IconData? icon; + final String? trailing; +} + +class SecondaryNavigationPane extends StatelessWidget { + const SecondaryNavigationPane({ + super.key, + required this.title, + required this.items, + required this.selectedValue, + required this.onSelected, + this.footer, + this.mobile = false, + this.showDisclosure = false, + this.width = AppDesignTokens.filterSidebarWidth, + }); + + final String title; + final List> items; + final T selectedValue; + final ValueChanged onSelected; + final Widget? footer; + final bool mobile; + final bool showDisclosure; + final double width; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Container( + key: const ValueKey('secondary-navigation-pane'), + width: mobile ? double.infinity : width, + color: palette.sideBg, + padding: EdgeInsets.only( + top: !mobile && AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: AppDesignTokens.contentHeaderHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + title.toUpperCase(), + style: TextStyle( + color: palette.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 2.4, + ), + ), + ), + ), + ), + const SizedBox(height: AppDesignTokens.space8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + for (final (index, item) in items.indexed) + _SecondaryNavigationPaneItem( + key: ValueKey('secondary-navigation-item-$index'), + item: item, + active: item.value == selectedValue, + mobile: mobile, + showDisclosure: showDisclosure, + onTap: () => onSelected(item.value), + ), + ], + ), + ), + if (footer != null) ...[const Spacer(), footer!] else const Spacer(), + ], + ), + ); + } +} + +class _SecondaryNavigationPaneItem extends StatelessWidget { + const _SecondaryNavigationPaneItem({ + super.key, + required this.item, + required this.active, + required this.mobile, + required this.showDisclosure, + required this.onTap, + }); + + final SecondaryNavigationPaneItem item; + final bool active; + final bool mobile; + final bool showDisclosure; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final content = MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + constraints: BoxConstraints(minHeight: mobile ? 52 : 0), + margin: EdgeInsets.only(bottom: mobile ? 6 : 4), + padding: EdgeInsets.symmetric(horizontal: mobile ? 14 : 12, vertical: mobile ? 12 : 8), + decoration: BoxDecoration( + color: active ? palette.itemActiveBg : Colors.transparent, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Row( + children: [ + if (item.icon != null) ...[ + Icon(item.icon, size: mobile ? 20 : 16, color: active ? palette.textPrimary : palette.textSecondary), + SizedBox(width: mobile ? 14 : 12), + ], + Expanded( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: mobile ? 16 : 14, + fontWeight: mobile ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + if (item.trailing != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: palette.cardHoverBg, + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Text( + item.trailing!, + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ) + else if (showDisclosure) + Icon(Icons.chevron_right, size: mobile ? 19 : 15, color: palette.textMuted), + ], + ), + ), + ), + ); + return content; + } +} diff --git a/ui/flutter/lib/shared/widgets/virtual_tree_view.dart b/ui/flutter/lib/shared/widgets/virtual_tree_view.dart new file mode 100644 index 00000000..af7aeafc --- /dev/null +++ b/ui/flutter/lib/shared/widgets/virtual_tree_view.dart @@ -0,0 +1,101 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; + +typedef VirtualTreeItemBuilder = Widget Function(BuildContext context, VirtualTreeEntry entry); +typedef VirtualTreeRowTransitionBuilder = + Widget Function(BuildContext context, VirtualTreeEntry entry, Widget row); + +class VirtualTreeEntry { + const VirtualTreeEntry({required this.node, required this.depth}); + + final TreeItemNode node; + final List depth; +} + +class VirtualTreeView extends StatelessWidget { + const VirtualTreeView({ + super.key, + required this.nodes, + required this.itemBuilder, + this.branchLine = BranchLine.path, + this.padding = const EdgeInsets.all(8), + this.rowHeight = 28, + this.cacheExtent = 280, + this.controller, + this.itemPadding, + this.transitionBuilder, + }); + + final List> nodes; + final VirtualTreeItemBuilder itemBuilder; + final BranchLine branchLine; + final EdgeInsetsGeometry padding; + final double rowHeight; + final double cacheExtent; + final ScrollController? controller; + final EdgeInsetsGeometry? itemPadding; + final VirtualTreeRowTransitionBuilder? transitionBuilder; + + @override + Widget build(BuildContext context) { + final entries = _flattenVisibleNodes(nodes); + final theme = Theme.of(context); + final densityGap = theme.density.baseGap * theme.scaling; + + return ListView.builder( + controller: controller, + padding: padding, + cacheExtent: cacheExtent, + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + Widget row = SizedBox( + height: rowHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var depthIndex = 1; depthIndex < entry.depth.length; depthIndex++) ...[ + SizedBox(width: densityGap), + SizedBox(width: densityGap * 2, child: branchLine.build(context, entry.depth, depthIndex)), + ], + Expanded( + child: Padding( + padding: itemPadding ?? EdgeInsets.symmetric(horizontal: densityGap, vertical: densityGap * 0.5), + child: itemBuilder(context, entry), + ), + ), + ], + ), + ); + final transition = transitionBuilder; + if (transition != null) { + row = transition(context, entry, row); + } + return row; + }, + ); + } +} + +List> _flattenVisibleNodes(List> nodes) { + final entries = >[]; + + void walk(List> currentNodes, List depth) { + for (var index = 0; index < currentNodes.length; index++) { + final node = currentNodes[index]; + if (node is TreeRootNode) { + walk(node.children, depth); + continue; + } + if (node is! TreeItemNode) continue; + final currentDepth = [...depth, TreeNodeDepth(index, currentNodes.length)]; + entries.add(VirtualTreeEntry(node: node, depth: currentDepth)); + if (node.expanded) { + walk(node.children, currentDepth); + } + } + } + + walk(nodes, const []); + return entries; +} diff --git a/ui/flutter/lib/shared/widgets/window/desktop_window_header.dart b/ui/flutter/lib/shared/widgets/window/desktop_window_header.dart new file mode 100644 index 00000000..5953bcad --- /dev/null +++ b/ui/flutter/lib/shared/widgets/window/desktop_window_header.dart @@ -0,0 +1,310 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; +import 'package:flutter/material.dart' show Colors; +import 'package:flutter/widgets.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../core/window/app_window_chrome.dart'; +import '../../theme/app_design_tokens.dart'; +import '../../theme/app_palette.dart'; + +class DesktopWindowHeader extends StatelessWidget { + const DesktopWindowHeader({super.key}); + + @override + Widget build(BuildContext context) { + if (!AppWindowChrome.usesCustomChrome) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: AppDesignTokens.windowHeaderHeight, + child: const Row( + children: [ + Expanded(child: DragToMoveArea(child: SizedBox.expand())), + WindowCaptionControls(), + ], + ), + ); + } +} + +class WindowCaptionControls extends StatefulWidget { + const WindowCaptionControls({super.key}); + + @override + State createState() => _WindowCaptionControlsState(); +} + +class _WindowCaptionControlsState extends State with WindowListener { + static const double _captionGlyphSize = 20; + bool _isMaximized = false; + + @override + void initState() { + super.initState(); + windowManager.addListener(this); + _refreshState(); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + void onWindowMaximize() => _refreshState(); + + @override + void onWindowUnmaximize() => _refreshState(); + + Future _refreshState() async { + final value = await windowManager.isMaximized(); + if (mounted) { + setState(() => _isMaximized = value); + } + } + + @override + Widget build(BuildContext context) { + if (!AppWindowChrome.usesCustomChrome) { + return const SizedBox.shrink(); + } + + final palette = AppPalette.of(context); + final textColor = palette.textSecondary; + final isLinux = defaultTargetPlatform == TargetPlatform.linux; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + _CaptionControlButton( + icon: const _MinimizeGlyph(), + foreground: textColor, + size: isLinux ? 28 : 30, + radius: isLinux ? 999 : 4, + background: isLinux ? const Color(0x0DFFFFFF) : Colors.transparent, + hoverBackground: isLinux ? const Color(0x1AE95420) : palette.captionButtonHover, + hoverForeground: textColor, + onPressed: windowManager.minimize, + ), + SizedBox(width: isLinux ? 8 : 4), + _CaptionControlButton( + icon: _isMaximized ? const _RestoreGlyph() : const _MaximizeGlyph(), + foreground: textColor, + size: isLinux ? 28 : 30, + radius: isLinux ? 999 : 4, + background: isLinux ? const Color(0x0DFFFFFF) : Colors.transparent, + hoverBackground: isLinux ? const Color(0x1AE95420) : palette.captionButtonHover, + hoverForeground: textColor, + onPressed: _isMaximized ? windowManager.unmaximize : windowManager.maximize, + ), + SizedBox(width: isLinux ? 8 : 4), + _CaptionControlButton( + icon: const _CloseGlyph(), + foreground: isLinux ? const Color(0xFFE95420) : textColor, + background: isLinux ? const Color(0x33E95420) : Colors.transparent, + size: isLinux ? 28 : 30, + radius: isLinux ? 999 : 4, + hoverBackground: isLinux ? const Color(0xFFE95420) : const Color(0xFFE81123), + hoverForeground: const Color(0xFFFFFFFF), + onPressed: windowManager.close, + ), + ], + ); + } +} + +class _CaptionControlButton extends StatefulWidget { + const _CaptionControlButton({ + required this.icon, + required this.foreground, + required this.onPressed, + required this.size, + required this.radius, + required this.background, + this.hoverBackground, + this.hoverForeground, + }); + + final Widget icon; + final Color foreground; + final VoidCallback onPressed; + final double size; + final double radius; + final Color background; + final Color? hoverBackground; + final Color? hoverForeground; + + @override + State<_CaptionControlButton> createState() => _CaptionControlButtonState(); +} + +class _CaptionControlButtonState extends State<_CaptionControlButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final background = _hovered ? (widget.hoverBackground ?? palette.captionButtonHover) : widget.background; + final foreground = _hovered ? (widget.hoverForeground ?? widget.foreground) : widget.foreground; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onPressed, + child: Container( + width: widget.size, + height: widget.size, + alignment: Alignment.center, + decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(widget.radius)), + child: IconTheme( + data: IconThemeData(color: foreground), + child: Center(child: widget.icon), + ), + ), + ), + ); + } +} + +class _MinimizeGlyph extends StatelessWidget { + const _MinimizeGlyph(); + + @override + Widget build(BuildContext context) { + final color = IconTheme.of(context).color ?? Colors.white; + final size = _WindowCaptionControlsState._captionGlyphSize; + return SizedBox( + width: size, + height: size, + child: Center( + child: Container( + width: size * 0.72, + height: size <= 12 ? 1.2 : 1.6, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(999)), + ), + ), + ); + } +} + +class _MaximizeGlyph extends StatelessWidget { + const _MaximizeGlyph(); + + @override + Widget build(BuildContext context) { + final color = IconTheme.of(context).color ?? Colors.white; + final size = _WindowCaptionControlsState._captionGlyphSize; + final boxSize = size * 0.62; + return SizedBox( + width: size, + height: size, + child: Center( + child: Container( + width: boxSize, + height: boxSize, + decoration: BoxDecoration( + border: Border.all(color: color, width: size <= 12 ? 0.9 : 0.95), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ); + } +} + +class _RestoreGlyph extends StatelessWidget { + const _RestoreGlyph(); + + @override + Widget build(BuildContext context) { + final color = IconTheme.of(context).color ?? Colors.white; + final size = _WindowCaptionControlsState._captionGlyphSize; + final boxSize = size * 0.56; + final topBoxLeft = size * 0.40; + final topBoxTop = size * 0.16; + final bottomBoxLeft = size * 0.16; + final bottomBoxTop = size * 0.40; + final borderWidth = size <= 12 ? 1.0 : 1.15; + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + Positioned( + left: topBoxLeft, + top: topBoxTop, + child: Container( + width: boxSize, + height: boxSize, + decoration: BoxDecoration( + border: Border.all(color: color, width: borderWidth), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + Positioned( + left: bottomBoxLeft, + top: bottomBoxTop, + child: Container( + width: boxSize, + height: boxSize, + decoration: BoxDecoration( + color: Colors.transparent, + border: Border.all(color: color, width: borderWidth), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ], + ), + ); + } +} + +class _CloseGlyph extends StatelessWidget { + const _CloseGlyph(); + + @override + Widget build(BuildContext context) { + final color = IconTheme.of(context).color ?? Colors.white; + return SizedBox( + width: _WindowCaptionControlsState._captionGlyphSize, + height: _WindowCaptionControlsState._captionGlyphSize, + child: CustomPaint(painter: _CrossPainter(color)), + ); + } +} + +class _CrossPainter extends CustomPainter { + const _CrossPainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = size.width <= 12 ? 1.2 : 1.8 + ..strokeCap = StrokeCap.round; + canvas.drawLine( + Offset(size.width * 0.28, size.height * 0.28), + Offset(size.width * 0.72, size.height * 0.72), + paint, + ); + canvas.drawLine( + Offset(size.width * 0.72, size.height * 0.28), + Offset(size.width * 0.28, size.height * 0.72), + paint, + ); + } + + @override + bool shouldRepaint(covariant _CrossPainter oldDelegate) { + return oldDelegate.color != color; + } +} diff --git a/ui/flutter/lib/util/analytics.dart b/ui/flutter/lib/util/analytics.dart index 18d5d568..cd149974 100644 --- a/ui/flutter/lib/util/analytics.dart +++ b/ui/flutter/lib/util/analytics.dart @@ -4,73 +4,92 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import 'package:gopeed/database/database.dart'; -import 'package:gopeed/util/package_info.dart'; - +import '../database/database.dart'; import 'log_util.dart'; +import 'package_info.dart'; -/// GA4 Measurement Protocol configuration from dart-define -class Config { - static const String measurementId = - String.fromEnvironment('GA4_MEASUREMENT_ID'); - static const String apiSecret = String.fromEnvironment('GA4_API_SECRET'); +/// GA4 Measurement Protocol configuration supplied by the release build. +class AnalyticsConfig { + const AnalyticsConfig._(); - static bool get isConfigured => - measurementId.isNotEmpty && apiSecret.isNotEmpty; + static const measurementId = String.fromEnvironment('GA4_MEASUREMENT_ID'); + static const apiSecret = String.fromEnvironment('GA4_API_SECRET'); + + static bool get isConfigured => measurementId.isNotEmpty && apiSecret.isNotEmpty; } -/// GA4 Measurement Protocol Analytics +/// Sends only anonymous application events. The preference is stored outside +/// downloader configuration so it remains local to the current installation. class Analytics { - static final Analytics _instance = Analytics._internal(); - static Analytics get instance => _instance; + Analytics._(); - Analytics._internal(); + static final instance = Analytics._(); - late String _clientId; - late int _sessionId; - late Dio _dio; + late final String _clientId; + late final int _sessionId; + late final Dio _dio; bool _initialized = false; Future init() async { - if (_initialized) return; - if (!Config.isConfigured) return; - + if (_initialized || !AnalyticsConfig.isConfigured) return; _clientId = await _getDeviceId(); - _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - )); _sessionId = DateTime.now().millisecondsSinceEpoch ~/ 1000; + _dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10))); _initialized = true; } + Future logAppOpen() => logEvent('app_open'); + + Future logEvent(String name, [Map? params]) async { + if (!_initialized) return; + final payload = { + 'client_id': _clientId, + 'timestamp_micros': DateTime.now().microsecondsSinceEpoch, + 'events': [ + { + 'name': name, + 'params': { + 'session_id': _sessionId.toString(), + 'engagement_time_msec': 100, + 'platform': _platformName(), + 'app_version': appVersion, + ...?params, + }, + }, + ], + }; + try { + await _dio.post( + 'https://www.google-analytics.com/mp/collect', + queryParameters: {'measurement_id': AnalyticsConfig.measurementId, 'api_secret': AnalyticsConfig.apiSecret}, + data: payload, + ); + } catch (error) { + logger.w('GA4Analytics: failed to send event "$name": $error'); + } + } + Future _getDeviceId() async { - final deviceInfo = DeviceInfoPlugin(); String? deviceId; try { + final info = DeviceInfoPlugin(); if (kIsWeb) { deviceId = null; } else if (Platform.isAndroid) { - final androidInfo = await deviceInfo.androidInfo; - deviceId = androidInfo.id; + deviceId = (await info.androidInfo).id; } else if (Platform.isIOS) { - final iosInfo = await deviceInfo.iosInfo; - deviceId = iosInfo.identifierForVendor; + deviceId = (await info.iosInfo).identifierForVendor; } else if (Platform.isMacOS) { - final macInfo = await deviceInfo.macOsInfo; - deviceId = macInfo.systemGUID; + deviceId = (await info.macOsInfo).systemGUID; } else if (Platform.isWindows) { - final windowsInfo = await deviceInfo.windowsInfo; - deviceId = windowsInfo.deviceId; + deviceId = (await info.windowsInfo).deviceId; } else if (Platform.isLinux) { - final linuxInfo = await deviceInfo.linuxInfo; - deviceId = linuxInfo.machineId; + deviceId = (await info.linuxInfo).machineId; } - } catch (e) { - debugPrint('GA4Analytics: Failed to get device id: $e'); + } catch (error) { + logger.w('GA4Analytics: failed to read device id: $error'); } - // Fallback to persisted client id if (deviceId == null || deviceId.isEmpty) { deviceId = Database.instance.getAnalyticsClientId(); if (deviceId == null || deviceId.isEmpty) { @@ -83,7 +102,7 @@ class Analytics { return deviceId; } - String _getPlatform() { + String _platformName() { if (kIsWeb) return 'web'; if (Platform.isAndroid) return 'android'; if (Platform.isIOS) return 'ios'; @@ -92,46 +111,4 @@ class Analytics { if (Platform.isLinux) return 'linux'; return 'unknown'; } - - Future logAppOpen() async { - await logEvent('app_open'); - } - - Future logEvent(String name, [Map? params]) async { - if (!_initialized) { - return; - } - - final eventParams = { - 'session_id': _sessionId.toString(), - 'engagement_time_msec': 100, - 'platform': _getPlatform(), - 'app_version': packageInfo.version, - ...?params, - }; - - final payload = { - 'client_id': _clientId, - 'timestamp_micros': DateTime.now().microsecondsSinceEpoch, - 'events': [ - { - 'name': name, - 'params': eventParams, - } - ], - }; - - try { - await _dio.post( - 'https://www.google-analytics.com/mp/collect', - queryParameters: { - 'measurement_id': Config.measurementId, - 'api_secret': Config.apiSecret, - }, - data: payload, - ); - } catch (e) { - logger.w('GA4Analytics: Failed to send event "$name": $e'); - } - } } diff --git a/ui/flutter/lib/util/arch/arch.dart b/ui/flutter/lib/util/arch/arch.dart index a096ebb3..b8a44f81 100644 --- a/ui/flutter/lib/util/arch/arch.dart +++ b/ui/flutter/lib/util/arch/arch.dart @@ -1,15 +1,6 @@ -import 'arch_stub.dart' - if (dart.library.io) 'entry/arch_native.dart' - if (dart.library.html) 'entry/arch_web.dart'; +import 'arch_stub.dart' if (dart.library.io) 'entry/arch_native.dart' if (dart.library.html) 'entry/arch_web.dart'; -// Copy from pkg/sky_engine/lib/ffi/abi.dart -enum Architecture { - arm, - arm64, - ia32, - x64, - riscv32, - riscv64, -} +// These names match the architecture suffixes used by Gopeed release assets. +enum Architecture { arm, arm64, ia32, x64, riscv32, riscv64 } Architecture getArch() => doGetArch(); diff --git a/ui/flutter/lib/util/arch/entry/arch_native.dart b/ui/flutter/lib/util/arch/entry/arch_native.dart index 5c73d894..16096779 100644 --- a/ui/flutter/lib/util/arch/entry/arch_native.dart +++ b/ui/flutter/lib/util/arch/entry/arch_native.dart @@ -1,13 +1,11 @@ -// ignore: avoid_web_libraries_in_flutter import 'dart:ffi'; import '../arch.dart'; Architecture doGetArch() { - final currentAbi = Abi.current().toString(); - final archName = currentAbi.split("_")[1]; - final arch = Architecture.values.firstWhere( - (element) => element.name == archName, - orElse: () => Architecture.x64); - return arch; + final archName = Abi.current().toString().split('_')[1]; + return Architecture.values.firstWhere( + (architecture) => architecture.name == archName, + orElse: () => Architecture.x64, + ); } diff --git a/ui/flutter/lib/util/arch/entry/arch_web.dart b/ui/flutter/lib/util/arch/entry/arch_web.dart index a401d0be..72143fd7 100644 --- a/ui/flutter/lib/util/arch/entry/arch_web.dart +++ b/ui/flutter/lib/util/arch/entry/arch_web.dart @@ -1,5 +1,3 @@ import '../arch.dart'; -Architecture doGetArch() { - return Architecture.x64; -} +Architecture doGetArch() => Architecture.x64; diff --git a/ui/flutter/lib/util/browser_download/browser_download.dart b/ui/flutter/lib/util/browser_download/browser_download.dart index 57b6a5ed..ef49232c 100644 --- a/ui/flutter/lib/util/browser_download/browser_download.dart +++ b/ui/flutter/lib/util/browser_download/browser_download.dart @@ -1,4 +1,3 @@ -import 'browser_download_stub.dart' - if (dart.library.html) 'entry/browser_download_browser.dart'; +import 'browser_download_stub.dart' if (dart.library.html) 'entry/browser_download_browser.dart'; void download(String url, String name) => doDownload(url, name); diff --git a/ui/flutter/lib/util/browser_download/browser_download_stub.dart b/ui/flutter/lib/util/browser_download/browser_download_stub.dart index 4b5e03a0..b1b99a4f 100644 --- a/ui/flutter/lib/util/browser_download/browser_download_stub.dart +++ b/ui/flutter/lib/util/browser_download/browser_download_stub.dart @@ -1 +1 @@ -void doDownload(String url, String name) => throw UnimplementedError(); +void doDownload(String url, String name) => throw UnsupportedError('Browser downloads are only available on web'); diff --git a/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart b/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart index 00619828..1eea6464 100644 --- a/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart +++ b/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart @@ -1,9 +1,9 @@ -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html; +import 'package:web/web.dart' as web; void doDownload(String url, String name) { - final anchorElement = html.AnchorElement(href: url); - anchorElement.download = name; - anchorElement.target = '_blank'; - anchorElement.click(); + final anchor = web.document.createElement('a') as web.HTMLAnchorElement + ..href = url + ..download = name + ..target = '_blank'; + anchor.click(); } diff --git a/ui/flutter/lib/util/browser_extension_host/browser_extension_host.dart b/ui/flutter/lib/util/browser_extension_host/browser_extension_host.dart index 545c98aa..146a0ca7 100644 --- a/ui/flutter/lib/util/browser_extension_host/browser_extension_host.dart +++ b/ui/flutter/lib/util/browser_extension_host/browser_extension_host.dart @@ -1,5 +1,4 @@ -import 'browser_extension_host_stub.dart' - if (dart.library.io) 'entry/browser_extension_host_native.dart'; +import 'browser_extension_host_stub.dart' if (dart.library.io) 'entry/browser_extension_host_native.dart'; enum Browser { chrome, edge, firefox } @@ -7,12 +6,10 @@ enum Browser { chrome, edge, firefox } Future installHost() => doInstallHost(); /// Check if specified browser is installed -Future checkBrowserInstalled(Browser browser) => - doCheckBrowserInstalled(browser); +Future checkBrowserInstalled(Browser browser) => doCheckBrowserInstalled(browser); /// Check if browser extension manifest is properly installed -Future checkManifestInstalled(Browser browser) => - doCheckManifestInstalled(browser); +Future checkManifestInstalled(Browser browser) => doCheckManifestInstalled(browser); /// Install browser extension manifest Future installManifest(Browser browser) => doInstallManifest(browser); diff --git a/ui/flutter/lib/util/browser_extension_host/browser_extension_host_stub.dart b/ui/flutter/lib/util/browser_extension_host/browser_extension_host_stub.dart index 05237510..48094dd7 100644 --- a/ui/flutter/lib/util/browser_extension_host/browser_extension_host_stub.dart +++ b/ui/flutter/lib/util/browser_extension_host/browser_extension_host_stub.dart @@ -1,8 +1,6 @@ import 'browser_extension_host.dart'; Future doInstallHost() => throw UnimplementedError(); -Future doCheckBrowserInstalled(Browser browser) => - throw UnimplementedError(); -Future doCheckManifestInstalled(Browser browser) => - throw UnimplementedError(); +Future doCheckBrowserInstalled(Browser browser) => throw UnimplementedError(); +Future doCheckManifestInstalled(Browser browser) => throw UnimplementedError(); Future doInstallManifest(Browser browser) => throw UnimplementedError(); diff --git a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart index 483a9e94..906b035d 100644 --- a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart +++ b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart @@ -31,8 +31,7 @@ const _firefoxNativeHostsKey = r'Software\Mozilla\NativeMessagingHosts'; /// Install host binary for browser extension Future doInstallHost() async { final hostPath = await Util.homePathJoin(_hostExecName); - await Util.installAsset('assets/exec/$_hostExecName', hostPath, - executable: true); + await Util.installAsset('assets/exec/$_hostExecName', hostPath, executable: true); } /// Check if specified browser is installed @@ -40,21 +39,16 @@ Future doCheckBrowserInstalled(Browser browser) async { if (Platform.isWindows) { switch (browser) { case Browser.chrome: - return await _checkWindowsRegistry( - r'SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe') || - await _checkWindowsRegistry( - r'SOFTWARE\WOW6432Node\Google\Chrome') || + return await _checkWindowsRegistry(r'SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe') || + await _checkWindowsRegistry(r'SOFTWARE\WOW6432Node\Google\Chrome') || await _checkWindowsExecutable(browser); case Browser.edge: - return await _checkWindowsRegistry( - r'SOFTWARE\Microsoft\Edge\BLBeacon') || - await _checkWindowsRegistry( - r'SOFTWARE\WOW6432Node\Microsoft\Edge\BLBeacon') || + return await _checkWindowsRegistry(r'SOFTWARE\Microsoft\Edge\BLBeacon') || + await _checkWindowsRegistry(r'SOFTWARE\WOW6432Node\Microsoft\Edge\BLBeacon') || await _checkWindowsExecutable(browser); case Browser.firefox: return await _checkWindowsRegistry(r'SOFTWARE\Mozilla\Firefox') || - await _checkWindowsRegistry( - r'SOFTWARE\WOW6432Node\Mozilla\Firefox') || + await _checkWindowsRegistry(r'SOFTWARE\WOW6432Node\Mozilla\Firefox') || await _checkWindowsRegistry(r'SOFTWARE\BrowserWorks\Waterfox') || await _checkWindowsExecutable(browser); } @@ -121,38 +115,22 @@ List _getWindowsExecutablePaths(Browser browser) { switch (browser) { case Browser.chrome: return [ - if (programFiles != null) - path.join( - programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'), - if (programFilesX86 != null) - path.join( - programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'), - if (localAppData != null) - path.join( - localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'), + if (programFiles != null) path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'), + if (programFilesX86 != null) path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'), + if (localAppData != null) path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'), ]; case Browser.edge: return [ - if (programFiles != null) - path.join( - programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), - if (programFilesX86 != null) - path.join(programFilesX86, 'Microsoft', 'Edge', 'Application', - 'msedge.exe'), - if (localAppData != null) - path.join( - localAppData, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + if (programFiles != null) path.join(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + if (programFilesX86 != null) path.join(programFilesX86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + if (localAppData != null) path.join(localAppData, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), ]; case Browser.firefox: return [ - if (programFiles != null) - path.join(programFiles, 'Mozilla Firefox', 'firefox.exe'), - if (programFilesX86 != null) - path.join(programFilesX86, 'Mozilla Firefox', 'firefox.exe'), - if (programFiles != null) - path.join(programFiles, 'Waterfox', 'waterfox.exe'), - if (programFilesX86 != null) - path.join(programFilesX86, 'Waterfox', 'waterfox.exe'), + if (programFiles != null) path.join(programFiles, 'Mozilla Firefox', 'firefox.exe'), + if (programFilesX86 != null) path.join(programFilesX86, 'Mozilla Firefox', 'firefox.exe'), + if (programFiles != null) path.join(programFiles, 'Waterfox', 'waterfox.exe'), + if (programFilesX86 != null) path.join(programFilesX86, 'Waterfox', 'waterfox.exe'), ]; } } @@ -175,13 +153,13 @@ List _getUnixExecutablePaths(Browser browser) { return [ '/Applications/Google Chrome.app', '~/Applications/Google Chrome.app', - '/Users/${Platform.environment['USER']}/Applications/Google Chrome.app' + '/Users/${Platform.environment['USER']}/Applications/Google Chrome.app', ]; case Browser.edge: return [ '/Applications/Microsoft Edge.app', '~/Applications/Microsoft Edge.app', - '/Users/${Platform.environment['USER']}/Applications/Microsoft Edge.app' + '/Users/${Platform.environment['USER']}/Applications/Microsoft Edge.app', ]; case Browser.firefox: return [ @@ -190,7 +168,7 @@ List _getUnixExecutablePaths(Browser browser) { '/Users/${Platform.environment['USER']}/Applications/Firefox.app', '/Applications/Waterfox.app', '~/Applications/Waterfox.app', - '/Users/${Platform.environment['USER']}/Applications/Waterfox.app' + '/Users/${Platform.environment['USER']}/Applications/Waterfox.app', ]; } } else { @@ -201,14 +179,14 @@ List _getUnixExecutablePaths(Browser browser) { '/usr/bin/google-chrome-stable', '/usr/bin/chrome', '/snap/bin/google-chrome', - '/opt/google/chrome/google-chrome' + '/opt/google/chrome/google-chrome', ]; case Browser.edge: return [ '/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable', '/snap/bin/microsoft-edge', - '/opt/microsoft/msedge/msedge' + '/opt/microsoft/msedge/msedge', ]; case Browser.firefox: return [ @@ -219,46 +197,53 @@ List _getUnixExecutablePaths(Browser browser) { '/usr/bin/waterfox', '/snap/bin/waterfox', '/usr/lib/waterfox/waterfox', - '/opt/waterfox/waterfox' + '/opt/waterfox/waterfox', ]; } } } Future _getManifestPath(Browser browser) async { - final manifestName = - browser == Browser.firefox ? '$_hostName.moz.json' : '$_hostName.json'; + final manifestName = browser == Browser.firefox ? '$_hostName.moz.json' : '$_hostName.json'; if (Platform.isWindows) { return await Util.homePathJoin(manifestName); } - final home = - Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null) return null; if (Platform.isMacOS) { switch (browser) { case Browser.chrome: - return path.join(home, 'Library', 'Application Support', 'Google', - 'Chrome', 'NativeMessagingHosts', manifestName); + return path.join( + home, + 'Library', + 'Application Support', + 'Google', + 'Chrome', + 'NativeMessagingHosts', + manifestName, + ); case Browser.edge: - return path.join(home, 'Library', 'Application Support', - 'Microsoft Edge', 'NativeMessagingHosts', manifestName); + return path.join( + home, + 'Library', + 'Application Support', + 'Microsoft Edge', + 'NativeMessagingHosts', + manifestName, + ); case Browser.firefox: - return path.join(home, 'Library', 'Application Support', 'Mozilla', - 'NativeMessagingHosts', manifestName); + return path.join(home, 'Library', 'Application Support', 'Mozilla', 'NativeMessagingHosts', manifestName); } } else if (Platform.isLinux) { switch (browser) { case Browser.chrome: - return path.join(home, '.config', 'google-chrome', - 'NativeMessagingHosts', manifestName); + return path.join(home, '.config', 'google-chrome', 'NativeMessagingHosts', manifestName); case Browser.edge: - return path.join(home, '.config', 'microsoft-edge', - 'NativeMessagingHosts', manifestName); + return path.join(home, '.config', 'microsoft-edge', 'NativeMessagingHosts', manifestName); case Browser.firefox: - return path.join( - home, '.mozilla', 'native-messaging-hosts', manifestName); + return path.join(home, '.mozilla', 'native-messaging-hosts', manifestName); } } return null; diff --git a/ui/flutter/lib/util/github_mirror.dart b/ui/flutter/lib/util/github_mirror.dart index 63fdccb6..d5183581 100644 --- a/ui/flutter/lib/util/github_mirror.dart +++ b/ui/flutter/lib/util/github_mirror.dart @@ -1,104 +1,50 @@ import 'package:dio/dio.dart'; -import 'package:get/get.dart'; import '../api/model/downloader_config.dart'; -import '../app/modules/app/controllers/app_controller.dart'; -enum MirrorType { - githubSource, - githubRelease, -} +enum MirrorType { githubSource, githubRelease } -/// Get the configured mirrors -List _getConfiguredMirrors() { - try { - final appController = Get.find(); - final config = appController.downloaderConfig.value.extra.githubMirror; +Future githubAutoMirror(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) async { + final mirrorUrls = githubMirrorUrls(rawUrl, type, config: config); + if (mirrorUrls.isEmpty) return rawUrl; - if (!config.enabled) { - return []; - } - - // Return configured mirrors (filtering not deleted ones) - return config.mirrors.where((m) => !m.isDeleted).toList(); - } catch (e) { - // Fallback to empty list if controller not found - return []; - } -} - -/// Auto detect the best mirror for the given [rawUrl] and [type] -Future githubAutoMirror(String rawUrl, MirrorType type) async { - final mirrorUrls = githubMirrorUrls(rawUrl, type); - - // If no mirrors, return original URL - if (mirrorUrls.isEmpty) { - return rawUrl; - } - - // Ping all mirrors and get the fastest one - final pingResult = await Future.wait(mirrorUrls.map((e) async { - final client = Dio() - ..options.sendTimeout = const Duration(seconds: 3) - ..options.connectTimeout = const Duration(seconds: 3); - var time = DateTime.now().millisecondsSinceEpoch; - try { - final response = await client.head(e); - if (response.statusCode == 200) { - time = DateTime.now().millisecondsSinceEpoch - time; - return (e, time); + final results = await Future.wait( + mirrorUrls.map((url) async { + final client = Dio( + BaseOptions(connectTimeout: const Duration(seconds: 3), sendTimeout: const Duration(seconds: 3)), + ); + final startedAt = DateTime.now(); + try { + final response = await client.head(url); + if (response.statusCode == 200) return (url, DateTime.now().difference(startedAt)); + } catch (_) { + // A failed mirror probe must never prevent the original download. + } finally { + client.close(); } - } catch (e) { - // ignore - } finally { - client.close(); - } - return (e, -1); - })); + return (url, null); + }), + ); - var list = pingResult.where((e) => e.$2 != -1).toList() - ..sort((a, b) => a.$2.compareTo(b.$2)); - if (list.isNotEmpty) { - return list.first.$1; - } - - return rawUrl; + final available = results.where((result) => result.$2 != null).toList() + ..sort((left, right) => left.$2!.compareTo(right.$2!)); + return available.isEmpty ? rawUrl : available.first.$1; } -List githubMirrorUrls(String rawUrl, MirrorType type) { - final mirrors = _getConfiguredMirrors(); - - final ret = []; - for (final mirror in mirrors) { - String? mirrorUrl; - - if (mirror.type == GithubMirrorType.jsdelivr) { - // jsdelivr only supports source files - if (type != MirrorType.githubSource) { - continue; - } - - // Transform: https://raw.githubusercontent.com/user/repo/master/path - // To: https://fastly.jsdelivr.net/gh/user/repo/path - final jsDelivrPattern = RegExp( - r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)'); - final match = jsDelivrPattern.firstMatch(rawUrl); - if (match != null) { - final user = match.group(1); - final repo = match.group(2); - final path = match.group(3); - mirrorUrl = '${mirror.url}$user$repo$path'; - } - } else if (mirror.type == GithubMirrorType.ghProxy) { - // gh-proxy supports both source and release - // Simply prepend the mirror URL to the original URL - mirrorUrl = '${mirror.url}/$rawUrl'; - } - - if (mirrorUrl != null) { - ret.add(mirrorUrl); +List githubMirrorUrls(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) { + if (!config.enabled) return const []; + final urls = []; + for (final mirror in config.mirrors.where((item) => !item.isDeleted)) { + switch (mirror.type) { + case GithubMirrorType.jsdelivr: + if (type != MirrorType.githubSource) continue; + final match = RegExp(r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)').firstMatch(rawUrl); + if (match != null) { + urls.add('${mirror.url}${match.group(1)}${match.group(2)}${match.group(3)}'); + } + case GithubMirrorType.ghProxy: + urls.add('${mirror.url}/$rawUrl'); } } - - return ret; + return urls; } diff --git a/ui/flutter/lib/util/log_util.dart b/ui/flutter/lib/util/log_util.dart index 7fc51e33..cf5799e8 100644 --- a/ui/flutter/lib/util/log_util.dart +++ b/ui/flutter/lib/util/log_util.dart @@ -6,7 +6,7 @@ import 'util.dart'; late final Logger logger; -initLogger() { +void initLogger() { // if is debug mode, don't log to file logger = Logger( filter: ProductionFilter(), @@ -19,7 +19,7 @@ String logsDir() { return path.join(Util.getStorageDir(), 'logs'); } -_buildOutput() { +LogOutput? _buildOutput() { // if is debug mode, don't log to file if (!kDebugMode && Util.isDesktop()) { final logDirPath = logsDir(); diff --git a/ui/flutter/lib/util/package_info.dart b/ui/flutter/lib/util/package_info.dart index 5a2d9164..9f32b399 100644 --- a/ui/flutter/lib/util/package_info.dart +++ b/ui/flutter/lib/util/package_info.dart @@ -2,6 +2,15 @@ import 'package:package_info_plus/package_info_plus.dart'; late PackageInfo packageInfo; +String get appVersion => normalizeAppVersion(packageInfo.version); + +String normalizeAppVersion(String platformVersion) { + final version = platformVersion.trim(); + final parts = version.split('.'); + if (parts.length != 4 || parts.any((part) => int.tryParse(part) == null)) return version; + return '${parts[0]}.${parts[1]}.${parts[2]}-beta.${parts[3]}'; +} + Future initPackageInfo() async { packageInfo = await PackageInfo.fromPlatform(); } diff --git a/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart b/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart index 426f85db..7f2c154b 100644 --- a/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart +++ b/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart @@ -1,32 +1,37 @@ import 'dart:io'; +import 'package:path/path.dart' as path; import 'package:win32_registry/win32_registry.dart'; import '../../util.dart'; import '../../win32.dart'; -doRegisterUrlScheme(String scheme) { +const _linuxDesktopFileName = 'com.gopeed.Gopeed.desktop'; + +void doRegisterUrlScheme(String scheme) { if (Util.isWindows()) { final schemeKey = 'Software\\Classes\\$scheme'; final appPath = Platform.resolvedExecutable; - upsertRegistry( - schemeKey, - 'URL Protocol', - '', - ); - upsertRegistry( - '$schemeKey\\shell\\open\\command', - '', - '"$appPath" "%1"', - ); + upsertRegistry(schemeKey, 'URL Protocol', ''); + upsertRegistry('$schemeKey\\shell\\open\\command', '', '"$appPath" "%1"'); + return; + } + + if (Util.isLinux()) { + _installLinuxDesktopEntry(mimeTypes: {'x-scheme-handler/$scheme'}); + _setLinuxDefaultMime('x-scheme-handler/$scheme'); } } -doUnregisterUrlScheme(String scheme) { +void doUnregisterUrlScheme(String scheme) { if (Util.isWindows()) { - Registry.currentUser - .deleteKey('Software\\Classes\\$scheme', recursive: true); + Registry.currentUser.deleteKey('Software\\Classes\\$scheme', recursive: true); + return; + } + + if (Util.isLinux()) { + return; } } @@ -37,41 +42,87 @@ const _torrentAppRegKey = 'Software\\Classes\\$_torrentRegValue'; /// Register as the system's default torrent client /// 1. Register the scheme "magnet" /// 2. Register the file type ".torrent" -doRegisterDefaultTorrentClient() { +void doRegisterDefaultTorrentClient() { if (Util.isWindows()) { doRegisterUrlScheme("magnet"); final appPath = Platform.resolvedExecutable; - final iconPath = - '${File(appPath).parent.path}\\data\\flutter_assets\\assets\\tray_icon\\icon.ico'; - upsertRegistry( - _torrentRegKey, - '', - _torrentRegValue, - ); - upsertRegistry( - _torrentAppRegKey, - '', - 'Torrent file', - ); - upsertRegistry( - '$_torrentAppRegKey\\DefaultIcon', - '', - iconPath, - ); - upsertRegistry( - '$_torrentAppRegKey\\shell\\open\\command', - '', - '"$appPath" "file:///%1"', - ); + final iconPath = '${File(appPath).parent.path}\\data\\flutter_assets\\assets\\tray_icon\\icon.ico'; + upsertRegistry(_torrentRegKey, '', _torrentRegValue); + upsertRegistry(_torrentAppRegKey, '', 'Torrent file'); + upsertRegistry('$_torrentAppRegKey\\DefaultIcon', '', iconPath); + upsertRegistry('$_torrentAppRegKey\\shell\\open\\command', '', '"$appPath" "file:///%1"'); + return; + } + + if (Util.isLinux()) { + _installLinuxDesktopEntry(mimeTypes: {'x-scheme-handler/magnet', 'application/x-bittorrent'}); + _setLinuxDefaultMime('x-scheme-handler/magnet'); + _setLinuxDefaultMime('application/x-bittorrent'); } } -doUnregisterDefaultTorrentClient() { +void doUnregisterDefaultTorrentClient() { if (Util.isWindows()) { doUnregisterUrlScheme("magnet"); Registry.currentUser.deleteKey(_torrentRegKey, recursive: true); Registry.currentUser.deleteKey(_torrentAppRegKey, recursive: true); + return; + } + + if (Util.isLinux()) { + return; } } + +void _installLinuxDesktopEntry({required Set mimeTypes}) { + final applicationsDir = path.join(Platform.environment['HOME'] ?? '', '.local', 'share', 'applications'); + if (applicationsDir.startsWith('.local')) { + return; + } + + final desktopFile = File(path.join(applicationsDir, _linuxDesktopFileName)); + final existingMimeTypes = _readLinuxDesktopMimeTypes(desktopFile); + final allMimeTypes = {'x-scheme-handler/gopeed', ...existingMimeTypes, ...mimeTypes}; + + desktopFile.parent.createSync(recursive: true); + desktopFile.writeAsStringSync(''' +[Desktop Entry] +Name=Gopeed +GenericName=Download Manager +Comment=A modern download manager for all platforms +Terminal=false +Exec=${Platform.resolvedExecutable} %U +Icon=com.gopeed.Gopeed +Type=Application +Categories=Utility;Network; +Keywords=Bittorrent;Downloader; +MimeType=${allMimeTypes.join(';')}; +'''); + + Process.runSync('update-desktop-database', [applicationsDir]); +} + +Set _readLinuxDesktopMimeTypes(File desktopFile) { + if (!desktopFile.existsSync()) { + return {}; + } + + for (final line in desktopFile.readAsLinesSync()) { + if (line.startsWith('MimeType=')) { + return line + .substring('MimeType='.length) + .split(';') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toSet(); + } + } + + return {}; +} + +void _setLinuxDefaultMime(String mimeType) { + Process.runSync('xdg-mime', ['default', _linuxDesktopFileName, mimeType]); +} diff --git a/ui/flutter/lib/util/scheme_register/scheme_register.dart b/ui/flutter/lib/util/scheme_register/scheme_register.dart index ee192e51..7c32559c 100644 --- a/ui/flutter/lib/util/scheme_register/scheme_register.dart +++ b/ui/flutter/lib/util/scheme_register/scheme_register.dart @@ -1,10 +1,9 @@ -import 'scheme_register_stub.dart' - if (dart.library.io) 'entry/scheme_register_native.dart'; +import 'scheme_register_stub.dart' if (dart.library.io) 'entry/scheme_register_native.dart'; -registerUrlScheme(String scheme) => doRegisterUrlScheme(scheme); +void registerUrlScheme(String scheme) => doRegisterUrlScheme(scheme); -unregisterUrlScheme(String scheme) => doUnregisterUrlScheme(scheme); +void unregisterUrlScheme(String scheme) => doUnregisterUrlScheme(scheme); -registerDefaultTorrentClient() => doRegisterDefaultTorrentClient(); +void registerDefaultTorrentClient() => doRegisterDefaultTorrentClient(); -unregisterDefaultTorrentClient() => doUnregisterDefaultTorrentClient(); +void unregisterDefaultTorrentClient() => doUnregisterDefaultTorrentClient(); diff --git a/ui/flutter/lib/util/scheme_register/scheme_register_stub.dart b/ui/flutter/lib/util/scheme_register/scheme_register_stub.dart index d4ea1623..9ce8da9e 100644 --- a/ui/flutter/lib/util/scheme_register/scheme_register_stub.dart +++ b/ui/flutter/lib/util/scheme_register/scheme_register_stub.dart @@ -1,7 +1,7 @@ -doRegisterUrlScheme(String scheme) => throw UnimplementedError(); +void doRegisterUrlScheme(String scheme) => throw UnimplementedError(); -doUnregisterUrlScheme(String scheme) => throw UnimplementedError(); +void doUnregisterUrlScheme(String scheme) => throw UnimplementedError(); -doRegisterDefaultTorrentClient() => throw UnimplementedError(); +void doRegisterDefaultTorrentClient() => throw UnimplementedError(); -doUnregisterDefaultTorrentClient() => throw UnimplementedError(); +void doUnregisterDefaultTorrentClient() => throw UnimplementedError(); diff --git a/ui/flutter/lib/util/updater.dart b/ui/flutter/lib/util/updater.dart index 514e3ff3..7da5040c 100644 --- a/ui/flutter/lib/util/updater.dart +++ b/ui/flutter/lib/util/updater.dart @@ -1,26 +1,23 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:install_plugin/install_plugin.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../api/api.dart'; +import '../api/api.dart' as api; import '../api/gopeed_site_api.dart'; -import '../app/views/outlined_button_loading.dart'; +import '../api/model/downloader_config.dart'; import 'arch/arch.dart'; import 'github_mirror.dart'; import 'log_util.dart'; -import 'message.dart'; import 'package_info.dart'; import 'util.dart'; -enum Channel { +enum UpdateChannel { windowsInstaller, windowsPortable, macosDmg, @@ -28,409 +25,241 @@ enum Channel { linuxSnap, linuxDeb, linuxAppImage, + linuxRpm, androidApk, iosIpa, docker, } -const _channelEnv = String.fromEnvironment("UPDATE_CHANNEL"); -final _channel = - Channel.values.where((e) => e.name == _channelEnv).firstOrNull ?? - () { - if (Util.isWindows()) { - return Channel.windowsPortable; - } else if (Util.isMacos()) { - return Channel.macosDmg; - } else if (Util.isAndroid()) { - return Channel.androidApk; - } else if (Util.isIOS()) { - return Channel.iosIpa; - } else { - return null; - } - }(); -final _updaterBin = "updater${Util.isWindows() ? ".exe" : ""}"; +const _channelEnv = String.fromEnvironment('UPDATE_CHANNEL'); -Future installUpdater() async { - await Util.installAsset( - 'assets/exec/$_updaterBin', await Util.homePathJoin(_updaterBin), - executable: true); +UpdateChannel? get updateChannel { + for (final channel in UpdateChannel.values) { + if (channel.name == _channelEnv) return channel; + } + if (Util.isWindows()) return UpdateChannel.windowsPortable; + if (Util.isMacos()) return UpdateChannel.macosDmg; + if (Util.isAndroid()) return UpdateChannel.androidApk; + if (Util.isIOS()) return UpdateChannel.iosIpa; + return null; } +String get _updaterBinaryName => 'updater${Util.isWindows() ? '.exe' : ''}'; + +const _releasePageSize = 10; +const _githubReleasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=$_releasePageSize'; + class VersionInfo { + const VersionInfo({required this.version, required this.changeLog, required this.releaseUrl}); + final String version; final String changeLog; - - VersionInfo(this.version, this.changeLog); + final String releaseUrl; } -Future checkUpdate() async { - String? releaseDataStr; - try { - releaseDataStr = (await proxyRequest( - "https://api.github.com/repos/GopeedLab/gopeed/releases/latest")) - .data; - } catch (e) { - releaseDataStr = jsonEncode(await GopeedSiteApi.instance.getRelease()); - } - if (releaseDataStr == null) { - return null; - } - final releaseData = jsonDecode(releaseDataStr); - final tagName = releaseData["tag_name"]; - if (tagName == null) { - return null; - } - final latestVersion = releaseData["tag_name"].substring(1); - - // compare version x.y.z to x.y.z - final currentVersion = packageInfo.version; - var isNewVersion = false; - if (latestVersion != currentVersion) { - final currentVersionList = currentVersion.split("."); - final latestVersionList = latestVersion.split("."); - for (var i = 0; i < currentVersionList.length; i++) { - if (int.parse(latestVersionList[i]) > int.parse(currentVersionList[i])) { - isNewVersion = true; - break; - } - } - } - - if (!isNewVersion) { - return null; - } - - return VersionInfo(latestVersion, releaseData["body"]); -} - -Future showUpdateDialog( - BuildContext context, VersionInfo versionInfo) async { - final fullChangeLog = versionInfo.changeLog; - final isZh = Get.locale?.languageCode == "zh"; - final changeLogRegex = isZh - ? RegExp(r"(#\s+更新日志.*)", multiLine: true, dotAll: true) - : RegExp(r"(# Release notes.*)#\s+更新日志", multiLine: true, dotAll: true); - final changeLog = changeLogRegex.firstMatch(fullChangeLog)?.group(1) ?? ""; - await showDialog( - context: Get.context!, - barrierDismissible: false, - builder: (context) { - bool isDownloading = false; - double progress = 0; - int total = 0; - final buttonController = OutlinedButtonLoadingController(); - return StatefulBuilder( - builder: (context, setState) { - final screenSize = MediaQuery.of(context).size; - final dialogWidth = - screenSize.width < 500 ? screenSize.width * 0.9 : 500.0; - final dialogHeight = - screenSize.height < 600 ? screenSize.height * 0.8 : 400.0; - - return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: SizedBox( - width: dialogWidth, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'newVersionTitle' - .trParams({'version': versionInfo.version}), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Container( - height: dialogHeight * 0.5, - decoration: BoxDecoration( - border: - Border.all(color: Theme.of(context).dividerColor), - borderRadius: BorderRadius.circular(8), - ), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - scrollbars: true, - ), - child: Builder( - builder: (context) { - final controller = ScrollController(); - return Scrollbar( - controller: controller, - thumbVisibility: true, - child: SingleChildScrollView( - controller: controller, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: - _parseMarkdown(changeLog, context), - ), - ), - ), - ); - }, - ), - ), - ), - if (isDownloading) ...[ - const SizedBox(height: 16), - LinearProgressIndicator( - value: progress, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${(progress * 100).toStringAsFixed(1)}%', - style: const TextStyle(fontSize: 12), - ), - Text( - total == 0 - ? '' - : '${Util.fmtByte((total * progress).toInt())} / ${Util.fmtByte(total)}', - style: const TextStyle(fontSize: 12), - ), - ], - ) - ], - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isDownloading ? null : () => Get.back(), - child: Text( - 'newVersionLater'.tr, - style: TextStyle( - color: isDownloading - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.error), - ), - ), - const SizedBox(width: 8), - OutlinedButtonLoading( - controller: buttonController, - onPressed: () async { - setState(() { - isDownloading = true; - }); - buttonController.start(); - try { - await _update(versionInfo.version, - (received, fileTotal) { - setState(() { - total = fileTotal; - progress = received / fileTotal; - }); - }); - } catch (e) { - showErrorMessage(e); - } finally { - setState(() { - isDownloading = false; - }); - buttonController.stop(); - } - }, - child: Text('newVersionUpdate'.tr), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, +/// Installs the small native updater bundled with desktop releases. +/// +/// Development builds do not necessarily contain this asset. Callers should +/// treat a missing asset as recoverable; [updateApp] falls back to the release +/// page when the helper is unavailable. +Future installUpdater() async { + if (!Util.isDesktop()) return; + await Util.installAsset( + 'assets/exec/$_updaterBinaryName', + await Util.homePathJoin(_updaterBinaryName), + executable: true, ); } -List _parseMarkdown(String markdown, BuildContext context) { - final List widgets = []; - final lines = markdown.split('\n'); - - for (final line in lines) { - if (line.trim().isEmpty) continue; - if (line.startsWith('# ')) { - // H1 header - widgets.add(Text( - line.substring(2), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - )); - } else if (line.startsWith('## ')) { - // H2 header - widgets.add(Text( - line.substring(3), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - )); - } else if (line.trim().startsWith('- ')) { - // List item - widgets.add(Padding( - padding: const EdgeInsets.only(left: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('• ', style: TextStyle(fontSize: 14)), - Expanded( - child: Text( - line.substring(line.indexOf('-') + 1).trim().replaceFirst( - RegExp(r'@[^\s]*\s\(#\d+\)'), - ''), // Remove contributor and pr number - style: const TextStyle(fontSize: 14), - ), - ), - ], - ), - )); - } else { - // Normal text - widgets.add(Text( - line, - style: const TextStyle(fontSize: 14), - )); +Future checkUpdate() async { + List releases; + try { + final releaseDataStr = (await api.proxyRequest(_githubReleasesUrl)).data; + if (releaseDataStr == null || releaseDataStr.isEmpty) { + throw const FormatException('Empty GitHub releases response'); } - - // Add spacing between elements - widgets.add(const SizedBox(height: 8)); + final releaseData = jsonDecode(releaseDataStr); + if (releaseData is! List) { + throw const FormatException('Invalid GitHub releases response'); + } + releases = releaseData; + } catch (_) { + releases = await GopeedSiteApi.instance.getReleases(perPage: _releasePageSize); } - - return widgets; + return selectUpdateRelease(releases, appVersion); } -Future _update(String version, Function(int, int) onProgress) async { - var newVersionAssetPath = ""; - final newVersionAssetName = _getAssetName(version); +bool isNewerVersion(String latest, String current) { + try { + return _parseVersion(latest).compareTo(_parseVersion(current)) > 0; + } on FormatException { + return false; + } +} - // Need to download the asset - if (newVersionAssetName.isNotEmpty) { - final downloadUrl = - 'https://github.com/GopeedLab/gopeed/releases/download/v$version/$newVersionAssetName'; - newVersionAssetPath = await _getAssetPath(version); +VersionInfo? selectUpdateRelease(List releases, String currentVersionText) { + late final Version currentVersion; + try { + currentVersion = _parseVersion(currentVersionText); + } on FormatException { + return null; + } - if (downloadUrl.isNotEmpty) { - final fastDownloadUrl = - await githubAutoMirror(downloadUrl, MirrorType.githubRelease); - final downloadClient = Dio(); - await downloadClient.download(fastDownloadUrl, newVersionAssetPath, - onReceiveProgress: onProgress); + Map? selectedRelease; + Version? selectedVersion; + + for (final item in releases) { + if (item is! Map) continue; + final release = Map.from(item); + if (release['draft'] == true) continue; + + final tagName = release['tag_name']; + if (tagName is! String || tagName.isEmpty) continue; + + late final Version candidateVersion; + try { + candidateVersion = _parseVersion(tagName); + } on FormatException { + continue; + } + + if (candidateVersion.compareTo(currentVersion) <= 0) continue; + + final candidateIsPrerelease = release['prerelease'] == true || candidateVersion.isPreRelease; + if (!currentVersion.isPreRelease) { + if (candidateIsPrerelease) continue; + } else if (candidateIsPrerelease && !_hasSameReleaseCore(candidateVersion, currentVersion)) { + // Preview builds graduate through their own beta/rc line before returning + // to stable; they do not jump into the next version's preview line. + continue; + } + + if (selectedVersion == null || candidateVersion.compareTo(selectedVersion) > 0) { + selectedRelease = release; + selectedVersion = candidateVersion; } } - switch (_channel) { - case Channel.windowsInstaller: - case Channel.windowsPortable: - case Channel.macosDmg: - case Channel.linuxFlathub: - case Channel.linuxSnap: - case Channel.linuxDeb: - final updaterPath = await Util.homePathJoin(_updaterBin); - // Check the updater binary is exists + if (selectedRelease == null || selectedVersion == null) return null; + final tagName = selectedRelease['tag_name'] as String; + return VersionInfo( + version: _versionText(tagName), + changeLog: (selectedRelease['body'] ?? '').toString(), + releaseUrl: (selectedRelease['html_url'] ?? 'https://github.com/GopeedLab/gopeed/releases/tag/$tagName').toString(), + ); +} + +Version _parseVersion(String version) => Version.parse(_versionText(version)); + +String _versionText(String version) => version.startsWith('v') ? version.substring(1) : version; + +bool _hasSameReleaseCore(Version first, Version second) => + first.major == second.major && first.minor == second.minor && first.patch == second.patch; + +/// Extracts the matching section from Gopeed's bilingual GitHub release notes. +String localizedReleaseNotes(String fullChangeLog, String languageCode) { + final isChinese = languageCode.toLowerCase().startsWith('zh'); + final chineseStart = RegExp(r'^#\s+更新日志', multiLine: true).firstMatch(fullChangeLog)?.start; + if (isChinese) { + return (chineseStart == null ? fullChangeLog : fullChangeLog.substring(chineseStart)).trim(); + } + final englishStart = RegExp(r'^#\s+Release notes', multiLine: true).firstMatch(fullChangeLog)?.start; + if (englishStart == null) return fullChangeLog.trim(); + final englishEnd = chineseStart != null && chineseStart > englishStart ? chineseStart : fullChangeLog.length; + return fullChangeLog.substring(englishStart, englishEnd).trim(); +} + +typedef UpdateProgressCallback = void Function(int received, int total); + +/// Downloads and applies [versionInfo] using the build-time release channel. +/// Store-distributed and unsupported channels safely open the release page. +Future updateApp( + VersionInfo versionInfo, { + required ExtraConfigGithubMirror githubMirror, + required UpdateProgressCallback onProgress, +}) async { + final channel = updateChannel; + final assetName = updateAssetName(versionInfo.version, channel: channel); + var assetPath = ''; + + if (assetName.isNotEmpty) { + final rawUrl = 'https://github.com/GopeedLab/gopeed/releases/download/v${versionInfo.version}/$assetName'; + assetPath = path.join((await getTemporaryDirectory()).path, assetName); + final downloadUrl = await githubAutoMirror(rawUrl, MirrorType.githubRelease, config: githubMirror); + final client = Dio(); + try { + await client.download(downloadUrl, assetPath, onReceiveProgress: onProgress); + } finally { + client.close(); + } + } + + switch (channel) { + case UpdateChannel.windowsInstaller: + case UpdateChannel.windowsPortable: + case UpdateChannel.macosDmg: + case UpdateChannel.linuxFlathub: + case UpdateChannel.linuxSnap: + case UpdateChannel.linuxDeb: + final updaterPath = await Util.homePathJoin(_updaterBinaryName); if (!await File(updaterPath).exists()) { - await launchUrl( - Uri.parse( - 'https://github.com/GopeedLab/gopeed/releases/tag/v$version'), - mode: LaunchMode.externalApplication); - break; + await _openRelease(versionInfo.releaseUrl); + return; } - /** - *Usage of updater command: - -pid int - PID of the process to update - -channel string - Update channel - -asset string - Path to the package asset - -exeDir string - Directory of the entry executable - -log string - Log file path - */ await Process.run(updaterPath, [ - "-pid", + '-pid', pid.toString(), - "-channel", - _channel!.name, - "-asset", - newVersionAssetPath, - "-exeDir", + '-channel', + channel!.name, + '-asset', + assetPath, + '-exeDir', path.dirname(Platform.resolvedExecutable), - "-log", - path.join(logsDir(), "updater.log") + '-log', + path.join(logsDir(), 'updater.log'), ]); - break; - case Channel.androidApk: - await InstallPlugin.installApk(newVersionAssetPath); - break; - default: - await launchUrl( - Uri.parse( - 'https://github.com/GopeedLab/gopeed/releases/tag/v$version'), - mode: LaunchMode.externalApplication); - break; + case UpdateChannel.androidApk: + await InstallPlugin.installApk(assetPath); + case UpdateChannel.linuxAppImage: + case UpdateChannel.linuxRpm: + case UpdateChannel.iosIpa: + case UpdateChannel.docker: + case null: + await _openRelease(versionInfo.releaseUrl); } } -String _getAssetName(String version) { - final arch = getArch(); - - String commonArchName() { - return switch (arch) { - Architecture.ia32 => "386", - Architecture.x64 => "amd64", - _ => arch.name - }; - } - - switch (_channel) { - case Channel.windowsInstaller: - return 'Gopeed-v$version-windows-${commonArchName()}.zip'; - case Channel.windowsPortable: - return 'Gopeed-v$version-windows-${commonArchName()}-portable.zip'; - case Channel.macosDmg: - return 'Gopeed-v$version-macos-${commonArchName()}.dmg'; - case Channel.linuxDeb: - return 'Gopeed-v$version-linux-${commonArchName()}.deb'; - case Channel.androidApk: - final apkArchName = switch (arch) { - Architecture.arm => "armeabi-v7a", - Architecture.arm64 => "arm64-v8a", - Architecture.x64 => "x86_64", - _ => null - }; - var apkNamePrefix = "Gopeed-v$version-android"; - if (apkArchName != null) { - apkNamePrefix += "-$apkArchName"; - } - return "$apkNamePrefix.apk"; - default: - return ""; - } +Future _openRelease(String releaseUrl) async { + final opened = await launchUrl(Uri.parse(releaseUrl), mode: LaunchMode.externalApplication); + if (!opened) throw StateError('Unable to open the release page.'); } -Future _getAssetPath(String version) async { - return path.join( - (await getTemporaryDirectory()).path, _getAssetName(version)); +String updateAssetName(String version, {UpdateChannel? channel, Architecture? architecture}) { + final targetChannel = channel ?? updateChannel; + final arch = architecture ?? getArch(); + + String commonArchName() => switch (arch) { + Architecture.ia32 => '386', + Architecture.x64 => 'amd64', + _ => arch.name, + }; + + return switch (targetChannel) { + UpdateChannel.windowsInstaller => 'Gopeed-v$version-windows-${commonArchName()}.zip', + UpdateChannel.windowsPortable => 'Gopeed-v$version-windows-${commonArchName()}-portable.zip', + UpdateChannel.macosDmg => 'Gopeed-v$version-macos-${commonArchName()}.dmg', + UpdateChannel.linuxDeb => 'Gopeed-v$version-linux-${commonArchName()}.deb', + UpdateChannel.androidApk => _androidAssetName(version, arch), + _ => '', + }; +} + +String _androidAssetName(String version, Architecture arch) { + final archName = switch (arch) { + Architecture.arm => 'armeabi-v7a', + Architecture.arm64 => 'arm64-v8a', + Architecture.x64 => 'x86_64', + _ => null, + }; + return 'Gopeed-v$version-android${archName == null ? '' : '-$archName'}.apk'; } diff --git a/ui/flutter/lib/util/util.dart b/ui/flutter/lib/util/util.dart index 58c1f29c..3e88406a 100644 --- a/ui/flutter/lib/util/util.dart +++ b/ui/flutter/lib/util/util.dart @@ -8,6 +8,8 @@ import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; +import '../core/utils/byte_size_formatter.dart'; + class Util { static String? _storageDir; @@ -30,32 +32,15 @@ class Util { } static String safePathJoin(List paths) { - return paths - .where((e) => e.isNotEmpty) - .map((e) => safeDir(e)) - .join("/") - .replaceAll(RegExp(r'//'), "/"); + return paths.where((e) => e.isNotEmpty).map((e) => safeDir(e)).join("/").replaceAll(RegExp(r'//'), "/"); } - static String fmtByte(int byte) { - if (byte < 0) { - return "0 B"; - } else if (byte < 1024) { - return "$byte B"; - } else if (byte < 1024 * 1024) { - return "${(byte / 1024).toStringAsFixed(2)} KB"; - } else if (byte < 1024 * 1024 * 1024) { - return "${(byte / 1024 / 1024).toStringAsFixed(2)} MB"; - } else { - return "${(byte / 1024 / 1024 / 1024).toStringAsFixed(2)} GB"; - } - } + static String fmtByte(int byte) => ByteSizeFormatter.format(byte); static Future initStorageDir() async { var storageDir = ""; if (Util.isWindows()) { - storageDir = - path.join(File(Platform.resolvedExecutable).parent.path, "storage"); + storageDir = path.join(File(Platform.resolvedExecutable).parent.path, "storage"); } else if (!Util.isWeb()) { if (Util.isLinux()) { storageDir = File(Platform.resolvedExecutable).parent.path; @@ -78,42 +63,42 @@ class Util { return _storageDir!; } - static isAndroid() { + static bool isAndroid() { return !kIsWeb && Platform.isAndroid; } - static isIOS() { + static bool isIOS() { return !kIsWeb && Platform.isIOS; } - static isMobile() { + static bool isMobile() { return !kIsWeb && (Platform.isAndroid || Platform.isIOS); } - static isDesktop() { + static bool isDesktop() { if (kIsWeb) { return false; } return Platform.isWindows || Platform.isLinux || Platform.isMacOS; } - static isWindows() { + static bool isWindows() { return !kIsWeb && Platform.isWindows; } - static isMacos() { + static bool isMacos() { return !kIsWeb && Platform.isMacOS; } - static isLinux() { + static bool isLinux() { return !kIsWeb && Platform.isLinux; } - static isWeb() { + static bool isWeb() { return kIsWeb; } - static supportUnixSocket() { + static bool supportUnixSocket() { if (kIsWeb) { return false; } @@ -129,22 +114,24 @@ class Util { } // if one future complete, return the result, only all future error, return the last error - static anyOk(Iterable> futures) { + static Future anyOk(Iterable> futures) { final completer = Completer(); Object lastError; var count = futures.length; for (var future in futures) { - future.then((value) { - if (!completer.isCompleted) { - completer.complete(value); - } - }).catchError((e) { - lastError = e; - count--; - if (count == 0) { - completer.completeError(lastError); - } - }); + future + .then((value) { + if (!completer.isCompleted) { + completer.complete(value); + } + }) + .catchError((e) { + lastError = e; + count--; + if (count == 0) { + completer.completeError(lastError); + } + }); } return completer.future; } @@ -168,8 +155,7 @@ class Util { return path.join(dir.path, fileName); } - static Future installAsset(String assetPath, String targetPath, - {bool executable = false}) async { + static Future installAsset(String assetPath, String targetPath, {bool executable = false}) async { Future> getAssetData() async { final asset = await rootBundle.load(assetPath); return asset.buffer.asUint8List(asset.offsetInBytes, asset.lengthInBytes); diff --git a/ui/flutter/lib/util/win32.dart b/ui/flutter/lib/util/win32.dart index c02a9a6f..e1324972 100644 --- a/ui/flutter/lib/util/win32.dart +++ b/ui/flutter/lib/util/win32.dart @@ -2,7 +2,7 @@ import 'package:win32_registry/win32_registry.dart'; /// Check registry key /// If the key does not exist or the value is different, return false -checkRegistry(String keyPath, String valueName, String value) { +bool checkRegistry(String keyPath, String valueName, String value) { RegistryKey regKey; try { regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath); @@ -15,18 +15,16 @@ checkRegistry(String keyPath, String valueName, String value) { /// Upsert registry key /// If the key does not exist, create it /// If the value does not exist or is different, update it -upsertRegistry(String keyPath, String valueName, String value) { +void upsertRegistry(String keyPath, String valueName, String value) { RegistryKey regKey; try { - regKey = Registry.openPath(RegistryHive.currentUser, - path: keyPath, desiredAccessRights: AccessRights.allAccess); + regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath, desiredAccessRights: AccessRights.allAccess); } catch (e) { regKey = Registry.currentUser.createKey(keyPath); } if (regKey.getValueAsString(valueName) != value) { - regKey - .createValue(RegistryValue(valueName, RegistryValueType.string, value)); + regKey.createValue(RegistryValue(valueName, RegistryValueType.string, value)); } regKey.close(); } diff --git a/ui/flutter/linux/CMakeLists.txt b/ui/flutter/linux/CMakeLists.txt index 5214f0f8..43bc901f 100644 --- a/ui/flutter/linux/CMakeLists.txt +++ b/ui/flutter/linux/CMakeLists.txt @@ -1,5 +1,5 @@ # Project-level configuration. -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change @@ -42,7 +42,9 @@ endif() function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_14) target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE -Wno-error=deprecated-declarations) # Allow deprecated warnings + # Some third-party plugins still call deprecated Linux APIs. Keep reporting + # those warnings without letting plugin deprecations fail the whole build. + target_compile_options(${TARGET} PRIVATE -Wno-error=deprecated-declarations) target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") endfunction() @@ -55,25 +57,8 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Define the application target. To change its name, change BINARY_NAME above, -# not the value here, or `flutter run` will no longer work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) @@ -87,6 +72,7 @@ set_target_properties(${BINARY_NAME} RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" ) + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -126,6 +112,12 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/ui/flutter/linux/bundle/lib/.gitkeep b/ui/flutter/linux/bundle/lib/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/ui/flutter/linux/bundle/lib/.gitkeep @@ -0,0 +1 @@ + diff --git a/ui/flutter/linux/runner/CMakeLists.txt b/ui/flutter/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/ui/flutter/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/ui/flutter/linux/runner/main.cc b/ui/flutter/linux/runner/main.cc new file mode 100644 index 00000000..cf8509c9 --- /dev/null +++ b/ui/flutter/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(argc, argv); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/ui/flutter/linux/runner/my_application.cc b/ui/flutter/linux/runner/my_application.cc new file mode 100644 index 00000000..d6355099 --- /dev/null +++ b/ui/flutter/linux/runner/my_application.cc @@ -0,0 +1,163 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "desktop_multi_window/desktop_multi_window_plugin.h" +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + + GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); + if (windows != nullptr) { + gtk_window_present(GTK_WINDOW(windows->data)); + return; + } + + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "gopeed"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "gopeed"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Realize the view so Flutter can render, but let Dart's window_manager + // bootstrap decide whether to show it. This preserves --hidden startup. + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry) { + fl_register_plugins(registry); + }); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + // Multi-window children are intentionally non-unique and have already been + // activated locally. Main-window launches continue through GApplication so + // command-line/open events can be forwarded to the primary instance. + return (g_application_get_flags(application) & G_APPLICATION_NON_UNIQUE) != 0; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new(int argc, char** argv) { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + const gboolean is_multi_window = + argc > 1 && g_strcmp0(argv[1], "multi_window") == 0; + const GApplicationFlags flags = + is_multi_window + ? G_APPLICATION_NON_UNIQUE + : static_cast(G_APPLICATION_HANDLES_COMMAND_LINE | + G_APPLICATION_HANDLES_OPEN); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", flags, nullptr)); +} diff --git a/ui/flutter/linux/runner/my_application.h b/ui/flutter/linux/runner/my_application.h new file mode 100644 index 00000000..7734b04f --- /dev/null +++ b/ui/flutter/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(int argc, char** argv); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/ui/flutter/macos/Podfile b/ui/flutter/macos/Podfile index 9ec46f8c..ff5ddb3b 100644 --- a/ui/flutter/macos/Podfile +++ b/ui/flutter/macos/Podfile @@ -28,9 +28,11 @@ flutter_macos_podfile_setup target 'Runner' do use_frameworks! - use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/ui/flutter/macos/Podfile.lock b/ui/flutter/macos/Podfile.lock index f43c948e..25728b41 100644 --- a/ui/flutter/macos/Podfile.lock +++ b/ui/flutter/macos/Podfile.lock @@ -1,29 +1,26 @@ PODS: - - app_links (1.0.0): + - app_links (6.4.1): - FlutterMacOS - desktop_drop (0.0.1): - FlutterMacOS + - desktop_multi_window (0.0.1): + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 6.0.3) - flutter_local_notifications (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - HotKey (0.2.1) - - hotkey_manager_macos (0.0.1): - - FlutterMacOS - - HotKey - open_dir_macos (0.0.1): - FlutterMacOS - OrderedSet (6.0.3) - package_info_plus (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - screen_retriever (0.0.1): + - screen_retriever_macos (0.0.1): - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS @@ -32,32 +29,34 @@ PODS: - FlutterMacOS - tray_manager (0.0.1): - FlutterMacOS + - uri_content (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - - window_manager (0.2.0): + - window_manager (0.5.0): - FlutterMacOS DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - open_dir_macos (from `Flutter/ephemeral/.symlinks/plugins/open_dir_macos/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) + - uri_content (from `Flutter/ephemeral/.symlinks/plugins/uri_content/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) SPEC REPOS: trunk: - - HotKey - OrderedSet EXTERNAL SOURCES: @@ -65,55 +64,57 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + desktop_multi_window: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos FlutterMacOS: :path: Flutter/ephemeral - hotkey_manager_macos: - :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos open_dir_macos: :path: Flutter/ephemeral/.symlinks/plugins/open_dir_macos/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - screen_retriever: - :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin tray_manager: :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos + uri_content: + :path: Flutter/ephemeral/.symlinks/plugins/uri_content/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - device_info_plus: 724ebb83a99d517430554a1810445ff28fe71fa3 + desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe open_dir_macos: 79810d7921d777f8732bf6358b3a0839a8fad9b5 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: 8147fd5037235202932580f35ebe195f7a55e3cd - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - screen_retriever: 4f97c103641aab8ce183fa5af3b87029df167936 - share_plus: b818fd6bf0e537317735186d977a68a125703488 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + uri_content: 5f98ff12ded3f2e1681b689828d747bafb03ba5f + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + window_manager: b729e31d38fb04905235df9ea896128991cad99e -PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/ui/flutter/macos/Runner.xcodeproj/project.pbxproj b/ui/flutter/macos/Runner.xcodeproj/project.pbxproj index 6fbaeeed..40a3b307 100644 --- a/ui/flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/macos/Runner.xcodeproj/project.pbxproj @@ -21,16 +21,25 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 0F970437545A111A20927325 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8592AF029594F4D655755C12 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - D024547D28D2E650009728A7 /* libgopeed.dylib in Embed Libraries */ = {isa = PBXBuildFile; fileRef = D024547C28D2E650009728A7 /* libgopeed.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + CDB1ECFD788A914D5DD8D89F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73199CD20DD5739B26C0E216 /* Pods_Runner.framework */; }; + D024547D28D2E650009728A7 /* libgopeed.dylib in Bundle Framework */ = {isa = PBXBuildFile; fileRef = D024547C28D2E650009728A7 /* libgopeed.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + E8178BDE77A876572B3280F6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 253127CFDFC434A1696A600D /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 33CC10E52044A3C60003C045 /* Project object */; @@ -41,25 +50,31 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - D024547928D2E58B009728A7 /* Embed Libraries */ = { + 33CC110E2044A8840003C045 /* Bundle Framework */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - D024547D28D2E650009728A7 /* libgopeed.dylib in Embed Libraries */, + D024547D28D2E650009728A7 /* libgopeed.dylib in Bundle Framework */, ); - name = "Embed Libraries"; + name = "Bundle Framework"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 1D1C571344AEA87ADEA020F0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 1DFC930634B94349932896F5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 00003E0C248BB0D18BE771D7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 06804B9BD12C6C51803CC5D2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 0B14A7E31B8606F6BB5B8573 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 0C5EAC98BBD24802C09D8E4C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 155052FC1CFA0FA35DE8428A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 253127CFDFC434A1696A600D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* gopeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gopeed.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Gopeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gopeed.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -71,25 +86,41 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 418692B5160A46EC301B4678 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 4A1E4D79A6EE8E71EE748E84 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 73199CD20DD5739B26C0E216 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 8592AF029594F4D655755C12 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; D024547C28D2E650009728A7 /* libgopeed.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libgopeed.dylib; path = Frameworks/libgopeed.dylib; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E8178BDE77A876572B3280F6 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 0F970437545A111A20927325 /* Pods_Runner.framework in Frameworks */, + CDB1ECFD788A914D5DD8D89F /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; 33BA886A226E78AF003329D5 /* Configs */ = { isa = PBXGroup; children = ( @@ -107,16 +138,18 @@ D024547C28D2E650009728A7 /* libgopeed.dylib */, 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 3923364A102657E047C00360 /* Pods */, + 8CDE8A169E44FDE0B9532B07 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* gopeed.app */, + 33CC10ED2044A3C60003C045 /* Gopeed.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -156,20 +189,25 @@ path = Runner; sourceTree = ""; }; - 3923364A102657E047C00360 /* Pods */ = { + 8CDE8A169E44FDE0B9532B07 /* Pods */ = { isa = PBXGroup; children = ( - 1DFC930634B94349932896F5 /* Pods-Runner.debug.xcconfig */, - 418692B5160A46EC301B4678 /* Pods-Runner.release.xcconfig */, - 1D1C571344AEA87ADEA020F0 /* Pods-Runner.profile.xcconfig */, + 06804B9BD12C6C51803CC5D2 /* Pods-Runner.debug.xcconfig */, + 4A1E4D79A6EE8E71EE748E84 /* Pods-Runner.release.xcconfig */, + 0B14A7E31B8606F6BB5B8573 /* Pods-Runner.profile.xcconfig */, + 0C5EAC98BBD24802C09D8E4C /* Pods-RunnerTests.debug.xcconfig */, + 00003E0C248BB0D18BE771D7 /* Pods-RunnerTests.release.xcconfig */, + 155052FC1CFA0FA35DE8428A /* Pods-RunnerTests.profile.xcconfig */, ); + name = Pods; path = Pods; sourceTree = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 8592AF029594F4D655755C12 /* Pods_Runner.framework */, + 73199CD20DD5739B26C0E216 /* Pods_Runner.framework */, + 253127CFDFC434A1696A600D /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -177,17 +215,36 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 1AA3ED235309989A317BDB2D /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - E5E56BC62824FB70A8F2274B /* [CP] Check Pods Manifest.lock */, + 00FD4CA682AC37D277B9AA86 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 5EF3421E5AE3B237C5D61783 /* [CP] Embed Pods Frameworks */, - D024547928D2E58B009728A7 /* Embed Libraries */, + 81ACFA8FD6D5264E84FBCBB4 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -196,7 +253,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* gopeed.app */; + productReference = 33CC10ED2044A3C60003C045 /* Gopeed.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -205,19 +262,19 @@ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; }; 33CC111A2044C6BA0003C045 = { CreatedOnToolsVersion = 9.2; @@ -239,12 +296,20 @@ projectRoot = ""; targets = ( 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10EB2044A3C60003C045 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -257,6 +322,50 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 00FD4CA682AC37D277B9AA86 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 1AA3ED235309989A317BDB2D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -295,7 +404,7 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 5EF3421E5AE3B237C5D61783 /* [CP] Embed Pods Frameworks */ = { + 81ACFA8FD6D5264E84FBCBB4 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -312,31 +421,17 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - E5E56BC62824FB70A8F2274B /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10E92044A3C60003C045 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -350,6 +445,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; @@ -370,11 +470,57 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C5EAC98BBD24802C09D8E4C /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gopeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gopeed"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 00003E0C248BB0D18BE771D7 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gopeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gopeed"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 155052FC1CFA0FA35DE8428A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gopeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gopeed"; + }; + name = Profile; + }; 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -398,9 +544,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -430,10 +578,6 @@ "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Frameworks", - ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -452,6 +596,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -475,9 +620,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -505,6 +652,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -528,9 +676,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -560,10 +710,6 @@ "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Frameworks", - ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -584,10 +730,6 @@ "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Frameworks", - ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -612,6 +754,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ui/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ui/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 550b2dbb..b184edeb 100644 --- a/ui/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ui/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -37,6 +37,17 @@ + + + + Void) -> Bool { - + override func application( + _ application: NSApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void + ) -> Bool { guard let url = AppLinks.shared.getUniversalLink(userActivity) else { return false } - AppLinks.shared.handleLink(link: url.absoluteString) - - return false // Returning true will stop the propagation to other packages + // Keep propagating the activity to other registered plugins. + return false } override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -32,23 +32,19 @@ class AppDelegate: FlutterAppDelegate { window.setIsVisible(true) } window.makeKeyAndOrderFront(self) - NSApp.activate(ignoringOtherApps: true) } + NSApp.activate(ignoringOtherApps: true) } return true } override func application(_ sender: NSApplication, openFile filename: String) -> Bool { - let url = URL(fileURLWithPath: filename) - AppLinks.shared.handleLink(link: url.absoluteString) - return false; + AppLinks.shared.handleLink(link: URL(fileURLWithPath: filename).absoluteString) + return true } override func application(_ application: NSApplication, open urls: [URL]) { - // Only handle the first file - if(urls.isEmpty) { - return - } - AppLinks.shared.handleLink(link: urls.first!.absoluteString) + guard let url = urls.first else { return } + AppLinks.shared.handleLink(link: url.absoluteString) } } diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 2779b74f..f4d11a66 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index b732b9cf..d7e8a54c 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index eb8ad6c6..39c03335 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index 344161f7..f4bed752 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index 1a3654b4..8ba12faf 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index c7de71c1..2b073569 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index ad1b3cd6..d4506abb 100644 Binary files a/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/ui/flutter/macos/Runner/Configs/AppInfo.xcconfig b/ui/flutter/macos/Runner/Configs/AppInfo.xcconfig index ca1418e4..a71dad0d 100644 --- a/ui/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/ui/flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = Gopeed PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2026 GopeedLab. All rights reserved. diff --git a/ui/flutter/macos/Runner/Info.plist b/ui/flutter/macos/Runner/Info.plist index 7eb8b3b0..cee06fe7 100644 --- a/ui/flutter/macos/Runner/Info.plist +++ b/ui/flutter/macos/Runner/Info.plist @@ -1,96 +1,96 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - CFBundleURLTypes - - - CFBundleURLName + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleURLName + gopeed + CFBundleURLSchemes + gopeed - CFBundleURLSchemes - - gopeed - - - - CFBundleURLName + + + + CFBundleURLName + magnet + CFBundleURLSchemes + magnet - CFBundleURLSchemes - - magnet - - - - CFBundleDocumentTypes - + + + + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + torrent + + CFBundleTypeIconFile + icon + CFBundleTypeName + BitTorrent Document + CFBundleTypeRole + Viewer + LSTypeIsPackage + + LSHandlerRank + Owner + LSItemContentTypes + + org.bittorrent.torrent + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.bittorrent.torrent + UTTypeDescription + Torrent File + UTTypeConformsTo + + public.data + + UTTypeTagSpecification - CFBundleTypeExtensions + public.filename-extension torrent - CFBundleTypeIconFile - icon - CFBundleTypeName - BitTorrent Document - CFBundleTypeRole - Viewer - LSTypeIsPackage - - LSHandlerRank - Owner - LSItemContentTypes - - org.bittorrent.torrent - + public.mime-type + application/x-bittorrent - - UTExportedTypeDeclarations - - - UTTypeIdentifier - org.bittorrent.torrent - UTTypeDescription - Torrent File - UTTypeConformsTo - - public.data - - UTTypeTagSpecification - - public.filename-extension - - torrent - - public.mime-type - application/x-bittorrent - - - - - \ No newline at end of file + + + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/ui/flutter/macos/Runner/MainFlutterWindow.swift b/ui/flutter/macos/Runner/MainFlutterWindow.swift index a823d046..718f4c04 100644 --- a/ui/flutter/macos/Runner/MainFlutterWindow.swift +++ b/ui/flutter/macos/Runner/MainFlutterWindow.swift @@ -1,15 +1,19 @@ import Cocoa import FlutterMacOS +import desktop_multi_window import window_manager class MainFlutterWindow: NSWindow { override func awakeFromNib() { - let flutterViewController = FlutterViewController.init() + let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in + RegisterGeneratedPlugins(registry: controller) + } super.awakeFromNib() } diff --git a/ui/flutter/macos/RunnerTests/RunnerTests.swift b/ui/flutter/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/ui/flutter/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index bc1d1d28..1a50ebf7 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -5,26 +5,34 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "10.0.1" + animation_kit: + dependency: transitive + description: + name: animation_kit + sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63 + url: "https://pub.dev" + source: hosted + version: "0.0.2" app_links: dependency: "direct main" description: name: app_links - sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.1" app_links_linux: dependency: transitive description: @@ -53,90 +61,66 @@ packages: dependency: transitive description: name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "4.2.0" args: - dependency: "direct main" + dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" - badges: - dependency: "direct main" - description: - name: badges - sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 - url: "https://pub.dev" - source: hosted - version: "3.1.2" + version: "2.13.1" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.3.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" url: "https://pub.dev" source: hosted - version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" + version: "4.1.5" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" + version: "2.15.1" built_collection: dependency: transitive description: @@ -149,10 +133,10 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.12.7" characters: dependency: transitive description: @@ -161,30 +145,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - checkable_treeview: - dependency: "direct main" - description: - name: checkable_treeview - sha256: "3d7543e2c93a968864a57ca0666fc3b5827d89d13b22cb637a427596032f9272" - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.4.1" + version: "0.4.2" clock: dependency: transitive description: @@ -193,14 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_builder: + code_assets: dependency: transitive description: - name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "1.2.1" collection: dependency: transitive description: @@ -209,14 +193,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - contentsize_tabbarview: - dependency: "direct main" - description: - name: contentsize_tabbarview - sha256: "4a3cf5e3a17bb5ebe5ee56d55c518f4dde7db639334b79f874f03ecea4755b22" - url: "https://pub.dev" - source: hosted - version: "0.0.2" convert: dependency: transitive description: @@ -225,30 +201,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + country_flags: + dependency: transitive + description: + name: country_flags + sha256: f022d18337f3861f1f4e319b936cb53920de9259f38cb09e169eace9942e2b79 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5+4" crypto: dependency: "direct main" description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dart_ipc: dependency: "direct main" description: @@ -261,18 +253,26 @@ packages: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "3.1.7" + data_widget: + dependency: transitive + description: + name: data_widget + sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a" + url: "https://pub.dev" + source: hosted + version: "0.0.3" dbus: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" desktop_drop: dependency: "direct main" description: @@ -281,46 +281,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.4" + desktop_multi_window: + dependency: "direct main" + description: + name: desktop_multi_window + sha256: "60ba38725b8887b60e44d15afdcf0c3813568b5da2ccaf1e7f6fd09a380a6e24" + url: "https://pub.dev" + source: hosted + version: "0.3.0" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: c4af09051b4f0508f6c1dc0a5c085bf014d5c9a4a0678ce1799c2b4d716387a0 + sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" url: "https://pub.dev" source: hosted - version: "11.1.0" + version: "11.3.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.3" dio: dependency: "direct main" description: name: dio - sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" url: "https://pub.dev" source: hosted - version: "5.7.0" + version: "5.11.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" url: "https://pub.dev" source: hosted - version: "2.0.0" - expandable_page_view: + version: "2.2.1" + email_validator: dependency: transitive description: - name: expandable_page_view - sha256: "210dc6961cfc29f7ed42867824eb699c9a4b9b198a7c04b8bdc1c05844969dc6" + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb url: "https://pub.dev" source: hosted - version: "1.0.17" + version: "3.0.0" + expressions: + dependency: transitive + description: + name: expressions + sha256: f3b0e99563a9a1bde1138e728eb722f292cc7d2aec55d28136c49b1a370306c5 + url: "https://pub.dev" + source: hosted + version: "0.2.5+3" + external_path: + dependency: "direct main" + description: + name: external_path + sha256: "68a18a2aa51ec012d7013ea2a80305dc5372f3577a2bbcc7dcc5550b25a5a73b" + url: "https://pub.dev" + source: hosted + version: "2.2.0" fake_async: dependency: transitive description: @@ -330,13 +354,13 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" ffigen: dependency: "direct dev" description: @@ -357,10 +381,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" + sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d" url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "9.0.2" fixnum: dependency: transitive description: @@ -374,30 +398,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_context_menu: - dependency: "direct main" - description: - name: flutter_context_menu - sha256: e8f2cd13ef5e3320e35b27b3f7de278d9fc6b3f9c328cc16cd90784fb66d1054 - url: "https://pub.dev" - source: hosted - version: "0.4.1" flutter_foreground_task: dependency: "direct main" description: name: flutter_foreground_task - sha256: "294280d8055af5628a8085bb765ba442561eaab3d5bcdf2eea4f20614f18618f" + sha256: "206017ee1bf864f34b8d7bce664a172717caa21af8da23f55866470dfe316644" url: "https://pub.dev" source: hosted - version: "8.2.0" - flutter_form_builder: - dependency: "direct main" - description: - name: flutter_form_builder - sha256: ec74389c4af2361a5e9fe9a36fcfe722698be3f681d713cb3ebe099ae15ed863 - url: "https://pub.dev" - source: hosted - version: "10.2.0" + version: "8.17.0" flutter_inappwebview: dependency: "direct main" description: @@ -466,18 +474,18 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: @@ -519,26 +527,26 @@ packages: dependency: "direct main" description: name: flutter_markdown_plus - sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935 url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.12" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.23" - flutter_svg: + version: "2.0.35" + flutter_riverpod: dependency: "direct main" description: - name: flutter_svg - sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "3.3.2" flutter_test: dependency: "direct dev" description: flutter @@ -549,14 +557,6 @@ packages: description: flutter source: sdk version: "0.0.0" - form_builder_validators: - dependency: "direct main" - description: - name: form_builder_validators - sha256: "1b03c74d1db740890e6af803b43e5ebe56f8fa1ff5609cbf744e8d980dc5f8c6" - url: "https://pub.dev" - source: hosted - version: "11.2.0" frontend_server_client: dependency: transitive description: @@ -565,22 +565,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - get: - dependency: "direct main" + gap: + dependency: transitive description: - name: get - sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d url: "https://pub.dev" source: hosted - version: "4.6.6" + version: "3.0.1" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c + url: "https://pub.dev" + source: hosted + version: "16.3.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" graphs: dependency: transitive description: @@ -593,10 +609,10 @@ packages: dependency: transitive description: name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" hive: dependency: "direct main" description: @@ -605,78 +621,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" - hotkey_manager: - dependency: "direct main" - description: - name: hotkey_manager - sha256: "06f0655b76c8dd322fb7101dc615afbdbf39c3d3414df9e059c33892104479cd" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - hotkey_manager_linux: + hooks: dependency: transitive description: - name: hotkey_manager_linux - sha256: "83676bda8210a3377bc6f1977f193bc1dbdd4c46f1bdd02875f44b6eff9a8473" + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "0.2.0" - hotkey_manager_macos: - dependency: transitive - description: - name: hotkey_manager_macos - sha256: "03b5967e64357b9ac05188ea4a5df6fe4ed4205762cb80aaccf8916ee1713c96" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hotkey_manager_platform_interface: - dependency: transitive - description: - name: hotkey_manager_platform_interface - sha256: "98ffca25b8cc9081552902747b2942e3bc37855389a4218c9d50ca316b653b13" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hotkey_manager_windows: - dependency: transitive - description: - name: hotkey_manager_windows - sha256: "0d03ced9fe563ed0b68f0a0e1b22c9ffe26eb8053cb960e401f68a4f070e0117" - url: "https://pub.dev" - source: hosted - version: "0.2.0" + version: "2.0.2" http: dependency: transitive description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.6.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.9.2" install_plugin: dependency: "direct main" description: @@ -698,34 +682,66 @@ packages: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" - js: + version: "1.0.5" + jni: dependency: transitive description: - name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jovial_misc: + dependency: transitive + description: + name: jovial_misc + sha256: "065b5240badae6b13472efdea28fffe8baf914a7831361469a95c6456d9b8dc8" + url: "https://pub.dev" + source: hosted + version: "0.10.0" + jovial_svg: + dependency: transitive + description: + name: jovial_svg + sha256: "99e9c3afcf7371ae38083ad52de23677d6d751f46150c3c6ae842e009e84d9f0" + url: "https://pub.dev" + source: hosted + version: "1.1.29" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.14.1" launch_at_startup: dependency: "direct main" description: @@ -758,30 +774,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - lecle_downloads_path_provider: - dependency: "direct main" - description: - name: lecle_downloads_path_provider - sha256: "111f6d6d21e18acddda515eba8fd445658aa14defcbd59513d6d1b02b9ed47a0" - url: "https://pub.dev" - source: hosted - version: "0.0.2+8" - lint: - dependency: transitive - description: - name: lint - sha256: "4a539aa34ec5721a2c7574ae2ca0336738ea4adc2a34887d54b7596310b33c85" - url: "https://pub.dev" - source: hosted - version: "1.10.0" lints: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "6.1.0" logger: dependency: "direct main" description: @@ -802,18 +802,18 @@ packages: dependency: transitive description: name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.3.1" matcher: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -846,6 +846,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" open_dir: dependency: "direct main" description: @@ -898,26 +914,26 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: df3eb3e0aed5c1107bb0fdb80a8e82e778114958b1c5ac5644fb1ac9cae8a998 + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.1" path: dependency: "direct main" description: @@ -926,54 +942,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.12" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -986,42 +994,42 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" url: "https://pub.dev" source: hosted - version: "11.3.1" + version: "11.4.0" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc url: "https://pub.dev" source: hosted - version: "12.0.13" + version: "12.1.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 url: "https://pub.dev" source: hosted - version: "9.4.5" + version: "9.6.1" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" url: "https://pub.dev" source: hosted - version: "0.1.3+5" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.4.0" permission_handler_windows: dependency: "direct overridden" description: @@ -1035,10 +1043,18 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "7.0.2" + phonecodes: + dependency: transitive + description: + name: phonecodes + sha256: d963c19d35914cd83620e64125689a0c09047e25046639f2a124142ccf5868bb + url: "https://pub.dev" + source: hosted + version: "0.0.4" platform: dependency: transitive description: @@ -1059,26 +1075,34 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" - pub_semver: + version: "1.5.2" + posix: dependency: transitive description: - name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "6.5.2" + pub_semver: + dependency: "direct main" + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" quiver: dependency: transitive description: @@ -1087,14 +1111,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" - rounded_loading_button_plus: - dependency: "direct main" + record_use: + dependency: transitive description: - name: rounded_loading_button_plus - sha256: "8baad9b66c4a81b02b597edf7691cf78f7267a999c08a68937e391af7b2da653" + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" + url: "https://pub.dev" + source: hosted + version: "3.3.2" rxdart: dependency: transitive description: @@ -1107,26 +1139,66 @@ packages: dependency: transitive description: name: screen_retriever - sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 url: "https://pub.dev" source: hosted - version: "0.1.9" + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + shadcn_flutter: + dependency: "direct main" + description: + name: shadcn_flutter + sha256: "52ce655e0d81e2fc5fa9e69a3871c1b98bd4fb34f62e7a58e9810a5bfe101955" + url: "https://pub.dev" + source: hosted + version: "0.0.53" share_handler: dependency: "direct main" description: name: share_handler - sha256: "76575533be04df3fecbebd3c5b5325a8271b5973131f8b8b0ab8490c395a5d37" + sha256: "0a6d007f0e44fbee27164adcd159ecbc88238864313f4e5c58161cae2180328d" url: "https://pub.dev" source: hosted - version: "0.0.22" + version: "0.0.25" share_handler_android: dependency: transitive description: name: share_handler_android - sha256: "124dcc914fb7ecd89076d3dc28435b98fe2129a988bf7742f7a01dcb66a95667" + sha256: caf555b933dc72783aa37fef75688c7b86bd6f7bc17d80fbf585bc42f123cc8d url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "0.0.11" share_handler_ios: dependency: transitive description: @@ -1147,42 +1219,42 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "334fcdf0ef9c0df0e3b428faebcac9568f35c747d59831474b2fc56e156d244e" + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.2" shared_preferences: dependency: transitive description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.7" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1195,18 +1267,18 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: @@ -1219,18 +1291,34 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" shortid: dependency: transitive description: @@ -1239,6 +1327,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + skeletonizer: + dependency: transitive + description: + name: skeletonizer + sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122" + url: "https://pub.dev" + source: hosted + version: "2.1.3" sky_engine: dependency: transitive description: flutter @@ -1248,34 +1344,42 @@ packages: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "4.2.4" source_helper: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.13" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -1284,6 +1388,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -1296,42 +1408,50 @@ packages: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" - styled_widget: - dependency: "direct main" - description: - name: styled_widget - sha256: "4d439802919b6ccf10d1488798656da8804633b03012682dd1c8ca70a084aa84" - url: "https://pub.dev" - source: hosted - version: "0.4.1" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" timezone: dependency: transitive description: @@ -1340,22 +1460,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - toggle_switch: - dependency: "direct main" - description: - name: toggle_switch - sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5 - url: "https://pub.dev" - source: hosted - version: "2.3.0" tray_manager: dependency: "direct main" description: @@ -1373,62 +1477,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - uni_platform: - dependency: transitive - description: - name: uni_platform - sha256: e02213a7ee5352212412ca026afd41d269eb00d982faa552f419ffc2debfad84 - url: "https://pub.dev" - source: hosted - version: "0.1.3" - uri_to_file: + uri_content: dependency: "direct main" description: - name: uri_to_file - sha256: cbbb38f975d22311efacc9a53cdcb806708606666d8092314b81c5cb7983b700 + name: uri_content + sha256: "34db4c58dcf934c92a72ca4943e1c363eaa2a9d525501b5216ed2870c4afb5d6" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "3.1.3" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "8fc3bae0b68c02c47c5c86fa8bfa74471d42687b0eded01b78de87872db745e2" + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" url: "https://pub.dev" source: hosted - version: "6.3.12" + version: "6.3.30" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1441,50 +1537,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.1" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" - url: "https://pub.dev" - source: hosted - version: "1.1.18" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" - url: "https://pub.dev" - source: hosted - version: "1.1.16" + version: "4.6.0" vector_math: dependency: transitive description: @@ -1497,34 +1569,34 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "15.2.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" web: - dependency: transitive + dependency: "direct main" description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: @@ -1533,14 +1605,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: name: win32 - sha256: "2294c64768987ea280b43a3d8357d42d5679f3e2b5b69b602be45b2abbd165b0" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.15.0" win32_registry: dependency: "direct main" description: @@ -1553,10 +1633,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: ab8b2a7f97543d3db2b506c9d875e637149d48ee0c6a5cb5f5fd6e0dac463792 + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.5.2" xdg_directories: dependency: transitive description: @@ -1569,26 +1649,26 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" yaml_edit: dependency: transitive description: name: yaml_edit - sha256: e9c1a3543d2da0db3e90270dbb1e4eebc985ee5e3ffe468d83224472b2194a5f + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.4" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.11.4 <4.0.0" + flutter: ">=3.38.4" diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 94f11644..a502a082 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -1,6 +1,5 @@ name: gopeed -description: A new Flutter project. - +description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: "none" # Remove this line if you wish to publish to pub.dev @@ -12,13 +11,15 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # build by specifying --build-name and --build-number, respectively. # In Android, build-name is used as versionName while build-number used as versionCode. # Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.9.3+1 +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 2.0.0-beta.1+1 environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ^3.11.4 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -34,63 +35,63 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - get: ^4.6.6 - styled_widget: ^0.4.0+3 - flutter_context_menu: ^0.4.1 - json_annotation: ^4.8.1 - dio: ^5.2.1 - path_provider: ^2.1.4 - file_picker: ^8.1.2 - rounded_loading_button_plus: ^3.0.1 - url_launcher: ^6.3.1 + cupertino_icons: ^1.0.8 + google_fonts: ^6.3.2 + shadcn_flutter: 0.0.53 + flutter_riverpod: ^3.0.3 + dio: ^5.9.0 + json_annotation: ^4.12.0 + ffi: ^2.1.3 + hive: ^2.2.3 + crypto: ^3.0.6 logger: ^1.4.0 - desktop_drop: ^0.4.4 package_info_plus: ^8.1.0 - path: ^1.9.0 - badges: ^3.0.3 + pub_semver: ^2.2.0 + device_info_plus: ^11.1.0 + win32_registry: ^1.1.5 + launch_at_startup: ^0.3.1 app_links: ^6.3.3 - uri_to_file: ^1.0.0 - window_manager: ^0.4.2 - hotkey_manager: ^0.2.3 + share_handler: ^0.0.22 share_plus: ^10.1.0 - flutter_form_builder: ^10.2.0 - form_builder_validators: ^11.0.0 - flutter_foreground_task: ^8.2.0 + uri_content: ^3.1.3 + dart_ipc: ^1.0.1 + flutter_inappwebview: ^6.1.5 + flutter_local_notifications: 20.1.0 + desktop_drop: ^0.4.4 + external_path: ^2.2.0 + file_picker: 9.0.2 + open_dir: ^0.0.2+1 open_filex: ^4.7.0 + permission_handler: ^11.3.1 + flutter_foreground_task: ^8.2.0 + flutter_markdown_plus: ^1.0.7 + path: ^1.9.1 + path_provider: ^2.1.5 + url_launcher: ^6.3.1 + web: ^1.1.1 + install_plugin: + git: + url: https://github.com/hui-z/flutter_install_plugin.git + ref: cf08af829f4a4145634f8a047108f505fdbe5eaa + window_manager: ^0.5.1 tray_manager: git: url: https://github.com/monkeyWie/tray_manager.git ref: main path: packages/tray_manager - lecle_downloads_path_provider: ^0.0.2+8 - hive: ^2.2.3 - launch_at_startup: ^0.3.1 - args: ^2.5.0 - toggle_switch: ^2.3.0 - permission_handler: ^11.3.1 - device_info_plus: ^11.1.0 - checkable_treeview: ^1.3.1 - contentsize_tabbarview: ^0.0.2 - win32_registry: ^1.1.5 - share_handler: ^0.0.22 - crypto: ^3.0.6 - open_dir: ^0.0.2+1 - install_plugin: - git: - url: https://github.com/hui-z/flutter_install_plugin.git - ref: cf08af829f4a4145634f8a047108f505fdbe5eaa - flutter_svg: ^2.0.17 - flutter_markdown_plus: ^1.0.3 - dart_ipc: ^1.0.1 - flutter_inappwebview: ^6.1.5 - flutter_local_notifications: 20.1.0 + desktop_multi_window: ^0.3.0 + go_router: ^16.2.1 + dependency_overrides: + # Upstream initializes a Windows Geolocator when the plugin registers, which + # makes Windows report location access even when the app never requests it. + # Keep this fork until https://github.com/Baseflow/flutter-permission-handler/issues/1394 is fixed and released. permission_handler_windows: git: url: https://github.com/monkeyWie/flutter-permission-handler.git ref: 35fc72c30262b9b49e1965b48a7524b44ba9daa7 path: permission_handler_windows + dev_dependencies: flutter_test: sdk: flutter @@ -100,46 +101,57 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^2.0.0 + flutter_lints: ^6.0.0 ffigen: ^8.0.2 - # build json model: flutter pub run build_runner build --delete-conflicting-outputs - build_runner: ^2.2.1 - json_serializable: ^6.3.2 - flutter_launcher_icons: ^0.13.1 + build_runner: ^2.15.1 + json_serializable: ^6.14.1 + flutter_launcher_icons: ^0.14.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. flutter: + generate: true + # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: assets: - - assets/tray_icon/ - assets/extension/ + - assets/tray_icon/ - assets/icon/ - # Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host - assets/exec/ + fonts: + - family: GopeedIcons + fonts: + - asset: assets/fonts/GopeedIcons.ttf + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware + # https://flutter.dev/to/resolution-aware-images # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages + # https://flutter.dev/to/asset-from-package # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: - fonts: - - family: Gopeed - fonts: - - asset: assets/fonts/Gopeed.ttf + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf @@ -147,12 +159,13 @@ flutter: # weight: 700 # # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages + # see https://flutter.dev/to/font-from-package -# flutter pub run flutter_launcher_icons:main -flutter_icons: +# Generate native launcher icons from the same source assets as the legacy UI: +# dart run flutter_launcher_icons +flutter_launcher_icons: image_path: "assets/icon/icon_1024.png" - android: true # can specify file name here e.g. "ic_launcher" + android: true ios: true remove_alpha_ios: true web: @@ -165,11 +178,19 @@ flutter_icons: generate: true image_path: "assets/icon/icon_macos_1024.png" -# flutter pub run ffigen +# Regenerate desktop native bindings with: flutter pub run ffigen ffigen: name: LibgopeedBind description: Bindings to gopeed library. output: "lib/core/ffi/libgopeed_bind.dart" + exclude-all-by-default: true headers: entry-points: - "include/libgopeed.h" + functions: + include: + - "Start" + - "Stop" + structs: + include: + - "Start_return" diff --git a/ui/flutter/test/api/downloader_config_test.dart b/ui/flutter/test/api/downloader_config_test.dart new file mode 100644 index 00000000..6db8eea8 --- /dev/null +++ b/ui/flutter/test/api/downloader_config_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; + +void main() { + test('auto-start tasks uses the backend-owned top-level config field', () { + final config = DownloaderConfig(autoStartTasks: true); + final json = config.toJson(); + + expect(json['autoStartTasks'], isTrue); + expect(json['extra'], isNot(contains('autoStartTasks'))); + expect(DownloaderConfig.fromJson(json).autoStartTasks, isTrue); + }); + + test('legacy extra auto-start setting is not migrated', () { + final json = DownloaderConfig().toJson(); + (json['extra'] as Map)['autoStartTasks'] = true; + + expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse); + }); +} diff --git a/ui/flutter/test/api/resolve_test.dart b/ui/flutter/test/api/resolve_test.dart new file mode 100644 index 00000000..ec31ddbc --- /dev/null +++ b/ui/flutter/test/api/resolve_test.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/api.dart' as api; +import 'package:gopeed/api/model/options.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resolve_task.dart'; + +void main() { + test('resolve sends the complete resolve task for magnet links', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + + late Map requestBody; + final responseHandled = Completer(); + server.listen((request) async { + requestBody = jsonDecode(await utf8.decoder.bind(request).join()) as Map; + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'code': 0, + 'data': { + 'id': 'resolved-id', + 'res': {'name': 'sample', 'size': 0, 'range': false, 'files': [], 'hash': ''}, + }, + }), + ); + await request.response.close(); + responseHandled.complete(); + }); + + api.init('tcp', '127.0.0.1:${server.port}', ''); + final result = await api.resolve( + ResolveTask( + req: Request(url: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'), + opts: Options(path: '/downloads', selectFiles: const []), + ), + ); + await responseHandled.future; + + expect(result.id, 'resolved-id'); + expect(requestBody.keys, containsAll(['req', 'opts'])); + expect( + (requestBody['req'] as Map)['url'], + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567', + ); + expect((requestBody['opts'] as Map)['path'], '/downloads'); + }); +} diff --git a/ui/flutter/test/api/task_stats_test.dart b/ui/flutter/test/api/task_stats_test.dart new file mode 100644 index 00000000..0b7b79ef --- /dev/null +++ b/ui/flutter/test/api/task_stats_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/task.dart'; +import 'package:gopeed/api/model/task_stats.dart'; + +void main() { + test('HTTP stats parse the current Go connection payload', () { + final stats = + TaskStats.fromJson(Protocol.http, { + 'snapshot': { + 'connections': [ + {'downloaded': 1024, 'total': 4096, 'completed': false, 'failed': false, 'retryTimes': 2}, + {'downloaded': 2048, 'total': 2048, 'completed': true, 'failed': false, 'retryTimes': 0}, + ], + }, + 'runtime': null, + }) + as HttpTaskStats; + + expect(stats.connections, hasLength(2)); + expect(stats.connections.first.downloaded, 1024); + expect(stats.connections.first.total, 4096); + expect(stats.connections.first.retryTimes, 2); + expect(stats.connections.last.completed, isTrue); + }); + + test('BT stats decode the ordered completion bitset and peer transport', () { + final stats = + TaskStats.fromJson(Protocol.bt, { + 'snapshot': { + 'seedBytes': 820, + 'seedRatio': 0.64, + 'seedTime': 3723, + 'pieceMap': { + 'encoding': 'bitset-v1', + 'pieceCount': 8, + 'pieceSize': 16384, + 'completedPieces': 3, + 'data': 'hQ==', + }, + }, + 'runtime': { + 'totalPeers': 128, + 'activePeers': 16, + 'connectedSeeders': 8, + 'connectedLeechers': 8, + 'peers': [ + { + 'address': '127.0.0.1:6881', + 'client': 'Gopeed', + 'downloadSpeed': 2048, + 'uploadSpeed': 1024, + 'pieceCount': 12, + 'completion': 0.75, + 'relevance': 0.25, + 'source': 'PEX', + 'transport': 'utp', + }, + ], + }, + }) + as BtTaskStats; + + expect(stats.activePeers, 16); + expect(stats.connectedLeechers, 8); + expect(stats.seedTime, 3723); + expect(stats.pieceMap.toStates(), [ + TaskPieceState.completed, + TaskPieceState.pending, + TaskPieceState.completed, + TaskPieceState.pending, + TaskPieceState.pending, + TaskPieceState.pending, + TaskPieceState.pending, + TaskPieceState.completed, + ]); + expect(stats.pieceMap.completedPieces, 3); + expect(stats.pieceMap.pieceSize, 16384); + expect(stats.peers.single.downloadSpeed, 2048); + expect(stats.peers.single.pieceCount, 12); + expect(stats.peers.single.completion, 0.75); + expect(stats.peers.single.relevance, 0.25); + expect(stats.peers.single.transport, 'utp'); + }); + + test('BT persisted snapshot remains useful when runtime is absent', () { + final stats = + TaskStats.fromJson(Protocol.bt, { + 'snapshot': { + 'seedBytes': 820, + 'seedRatio': 0.64, + 'seedTime': 65, + 'pieceMap': { + 'encoding': 'bitset-v1', + 'pieceCount': 4, + 'pieceSize': 16384, + 'completedPieces': 2, + 'data': 'Aw==', + }, + }, + 'runtime': null, + }) + as BtTaskStats; + + expect(stats.totalPeers, 0); + expect(stats.activePeers, 0); + expect(stats.connectedLeechers, 0); + expect(stats.seedTime, 65); + expect(stats.peers, isEmpty); + expect(stats.pieceMap.completedPieces, 2); + expect(stats.pieceMap.toStates(), [ + TaskPieceState.completed, + TaskPieceState.completed, + TaskPieceState.pending, + TaskPieceState.pending, + ]); + }); + + test('piece map rejects unsupported versions and malformed byte lengths', () { + expect( + TaskPieceMap.fromJson({ + 'encoding': 'bitset-v2', + 'pieceCount': 4, + 'pieceSize': 1024, + 'completedPieces': 0, + 'data': 'AA==', + }), + isNull, + ); + expect( + TaskPieceMap.fromJson({ + 'encoding': 'bitset-v1', + 'pieceCount': 9, + 'pieceSize': 1024, + 'completedPieces': 0, + 'data': 'AA==', + }), + isNull, + ); + }); + + test('stats models ignore fields that are not part of the Go response contract', () { + final http = + TaskStats.fromJson(Protocol.http, { + 'snapshot': { + 'connections': [ + {'received': 1024, 'completed': false, 'failed': false, 'retries': 2}, + ], + }, + }) + as HttpTaskStats; + final bt = + TaskStats.fromJson(Protocol.bt, { + 'snapshot': { + 'uploaded': 1024, + 'shareRatio': 0.5, + 'pieces': [2, 1, 0], + }, + 'runtime': {'seeders': 8}, + }) + as BtTaskStats; + + expect(http.connections.single.downloaded, 0); + expect(http.connections.single.retryTimes, 0); + expect(bt.connectedSeeders, 0); + expect(bt.seedBytes, 0); + expect(bt.seedRatio, 0); + expect(bt.pieceMap.isEmpty, isTrue); + }); + + test('ED2K stats parse the existing aggregate fields', () { + final stats = + TaskStats.fromJson(Protocol.ed2k, { + 'snapshot': {'upload': 320, 'totalDone': 68, 'totalWanted': 100}, + 'runtime': {'activePeers': 12, 'totalPeers': 86, 'downloadRate': 4800000, 'uploadRate': 320000}, + }) + as Ed2kTaskStats; + + expect(stats.totalPeers, 86); + expect(stats.downloadRate, 4800000); + expect(stats.totalDone / stats.totalWanted, 0.68); + expect(stats.peers, isEmpty); + }); + + test('a missing protocol has no statistics panel', () { + expect( + TaskStats.fromJson(null, { + 'snapshot': {'value': 1}, + }), + isNull, + ); + }); +} diff --git a/ui/flutter/test/app_deep_link_controller_test.dart b/ui/flutter/test/app_deep_link_controller_test.dart new file mode 100644 index 00000000..388bc53f --- /dev/null +++ b/ui/flutter/test/app_deep_link_controller_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/app/application/app_deep_link_controller.dart'; +import 'package:share_handler/share_handler.dart'; + +void main() { + test('keeps the established path-style Gopeed deep-link routes', () { + expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///create')), '/create'); + expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///extension')), '/extension'); + }); + + test('rejects host-style Gopeed deep-link routes', () { + expect(gopeedDeepLinkRoute(Uri.parse('gopeed://create')), isEmpty); + expect(gopeedDeepLinkRoute(Uri.parse('gopeed://extension')), isEmpty); + }); + + test('uses a shared attachment path before an optional caption', () { + final media = SharedMedia( + content: 'https://example.com/caption', + attachments: [SharedAttachment(path: '/tmp/example.torrent', type: SharedAttachmentType.file)], + ); + + expect(sharedMediaUri(media), Uri.file('/tmp/example.torrent')); + }); + + test('uses shared text when there is no attachment', () { + final media = SharedMedia(content: ' magnet:?xt=urn:btih:example '); + + expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example')); + }); +} diff --git a/ui/flutter/test/app_theme_test.dart b/ui/flutter/test/app_theme_test.dart new file mode 100644 index 00000000..96d13491 --- /dev/null +++ b/ui/flutter/test/app_theme_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_palette.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/detail/app_detail_surface_desktop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + test('Android themes use platform fonts', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final typography = AppTheme.light().typography; + + expect(typography.sans.fontFamily, isNull); + expect(typography.mono.fontFamily, 'monospace'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + test('Windows themes use a deterministic CJK font fallback', () { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + final shadTheme = AppTheme.light(); + final materialTheme = AppTheme.materialLight(); + + expect(shadTheme.typography.sans.fontFamily, 'packages/shadcn_flutter/GeistSans'); + expect(shadTheme.typography.sans.fontFamilyFallback, const ['Microsoft YaHei UI']); + expect(materialTheme.textTheme.bodyMedium?.fontFamilyFallback, const ['Microsoft YaHei UI']); + expect(materialTheme.primaryTextTheme.bodyMedium?.fontFamilyFallback, const ['Microsoft YaHei UI']); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + test('light theme separates navigation, canvas, and raised surfaces', () { + const palette = AppPalette.light; + + expect(palette.railBg, isNot(palette.sideBg)); + expect(palette.sideBg, isNot(palette.bg)); + expect(palette.bg, isNot(palette.cardBg)); + expect(palette.railBg, isNot(palette.cardBg)); + }); + + testWidgets('desktop detail drawer is raised only in the light theme', (tester) async { + const drawerKey = ValueKey('palette-test-detail-drawer'); + + Future pumpDrawer({required bool light}) async { + final palette = light ? AppPalette.light : AppPalette.dark; + await tester.pumpWidget( + shad.ShadcnApp( + theme: light ? AppTheme.light() : AppTheme.dark(), + materialTheme: light ? AppTheme.materialLight() : AppTheme.materialDark(), + home: SizedBox.expand( + child: AppDetailDrawer( + open: true, + title: 'Details', + onClose: () {}, + drawerKey: drawerKey, + child: const SizedBox(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final drawer = tester.widget(find.byKey(drawerKey)); + final decoration = drawer.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.color, light ? palette.headerDivider : palette.border); + expect(border.top.color, palette.headerDivider); + expect(find.byKey(const ValueKey('app-detail-drawer-close-button')), findsOneWidget); + return decoration; + } + + final lightDecoration = await pumpDrawer(light: true); + expect(lightDecoration.color, AppPalette.light.cardBg); + expect(lightDecoration.boxShadow, isNotEmpty); + + final darkDecoration = await pumpDrawer(light: false); + expect(darkDecoration.color, AppPalette.dark.bg); + expect(darkDecoration.boxShadow, isNull); + }); +} diff --git a/ui/flutter/test/core/capabilities/capability_rpc_test.dart b/ui/flutter/test/core/capabilities/capability_rpc_test.dart new file mode 100644 index 00000000..a6b1dd78 --- /dev/null +++ b/ui/flutter/test/core/capabilities/capability_rpc_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/window/app_window_appearance.dart'; + +class _Payload { + const _Payload(this.value); + + factory _Payload.fromJson(Map json) => _Payload(json['value'] as int); + + final int value; + + Map toJson() => {'value': value}; +} + +void main() { + test('one method descriptor supports direct and serialized dispatch', () async { + const method = RpcMethod<_Payload, _Payload>('test.increment'); + final codecs = RpcCodecRegistry()..register<_Payload>((json) => _Payload.fromJson(json! as Map)); + final registry = CapabilityRegistry(codecs)..bind(method, (payload) => _Payload(payload.value + 1)); + + final local = await LocalCapabilityInvoker(registry).invoke(method, const _Payload(1)); + final serialized = codecs.decode<_Payload>(await registry.invoke(method.name, codecs.encode(const _Payload(4)))); + + expect(local.value, 2); + expect(serialized.value, 5); + }); + + test('appearance snapshots use the shared codec registry', () { + final codecs = RpcCodecRegistry() + ..register((json) => AppWindowAppearance.fromJson(json! as Map)); + const appearance = AppWindowAppearance(themeMode: 'dark', themeColor: 'blue', locale: 'zh'); + + expect(codecs.decode(codecs.encode(appearance)), appearance); + }); + + test('unknown capabilities return a structured error', () async { + final registry = CapabilityRegistry(RpcCodecRegistry()); + + expect( + () => registry.invoke('missing', null), + throwsA(isA().having((error) => error.code, 'code', 'method_not_found')), + ); + }); +} diff --git a/ui/flutter/test/core/entry/app_startup_options_test.dart b/ui/flutter/test/core/entry/app_startup_options_test.dart new file mode 100644 index 00000000..f6e006fb --- /dev/null +++ b/ui/flutter/test/core/entry/app_startup_options_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/entry/app_startup_options.dart'; + +void main() { + test('starts hidden for the launch-at-startup flag', () { + expect(AppStartupOptions.fromArgs(const ['--hidden']).hidden, isTrue); + }); + + test('starts hidden for the browser host silent wake URI', () { + expect(AppStartupOptions.fromArgs(const ['gopeed:?hidden=true']).hidden, isTrue); + expect(AppStartupOptions.fromArgs(const ['gopeed:///?hidden=true']).hidden, isTrue); + }); + + test('does not hide task and extension deep links', () { + expect(AppStartupOptions.fromArgs(const ['gopeed:///create?hidden=true']).hidden, isFalse); + expect(AppStartupOptions.fromArgs(const ['gopeed:///extension']).hidden, isFalse); + }); + + test('merges a platform-provided initial URI before the window is shown', () { + final options = AppStartupOptions.fromArgs(const []); + + expect(options.withInitialUri(Uri.parse('gopeed:?hidden=true')).hidden, isTrue); + expect(options.withInitialUri(Uri.parse('gopeed:///create')).hidden, isFalse); + }); + + test('recognizes only the root hidden wake URI', () { + expect(isSilentGopeedWakeUri(Uri.parse('gopeed:?hidden=true')), isTrue); + expect(isSilentGopeedWakeUri(Uri.parse('gopeed:///?hidden=true')), isTrue); + expect(isSilentGopeedWakeUri(Uri.parse('gopeed:///create?hidden=true')), isFalse); + expect(isSilentGopeedWakeUri(Uri.parse('https://example.com/?hidden=true')), isFalse); + }); +} diff --git a/ui/flutter/test/core/utils/byte_size_formatter_test.dart b/ui/flutter/test/core/utils/byte_size_formatter_test.dart new file mode 100644 index 00000000..9b51375f --- /dev/null +++ b/ui/flutter/test/core/utils/byte_size_formatter_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/byte_size_formatter.dart'; + +void main() { + test('formats scaled byte sizes with up to two decimal places', () { + expect(ByteSizeFormatter.format(-1), '0 B'); + expect(ByteSizeFormatter.format(512), '512 B'); + expect(ByteSizeFormatter.format(1536), '1.5 KB'); + expect(ByteSizeFormatter.format(1280), '1.25 KB'); + expect(ByteSizeFormatter.format(32 * 1024 * 1024), '32 MB'); + expect(ByteSizeFormatter.format(2 * 1024 * 1024 * 1024), '2 GB'); + }); +} diff --git a/ui/flutter/test/core/utils/content_uri_resolver_test.dart b/ui/flutter/test/core/utils/content_uri_resolver_test.dart new file mode 100644 index 00000000..d93831cc --- /dev/null +++ b/ui/flutter/test/core/utils/content_uri_resolver_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/content_uri_resolver.dart'; + +void main() { + test('rejects non-content URIs', () async { + expect(() => ContentUriResolver.copyToCache(Uri.parse('file:///tmp/example.torrent')), throwsArgumentError); + }); +} diff --git a/ui/flutter/test/core/utils/transfer_rate_formatter_test.dart b/ui/flutter/test/core/utils/transfer_rate_formatter_test.dart new file mode 100644 index 00000000..645feec1 --- /dev/null +++ b/ui/flutter/test/core/utils/transfer_rate_formatter_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/transfer_rate_formatter.dart'; + +void main() { + test('formats transfer rates with adaptive binary units', () { + expect(TransferRateFormatter.format(-1).text, '0 B/s'); + expect(TransferRateFormatter.format(512).text, '512 B/s'); + expect(TransferRateFormatter.format(96 * 1024).text, '96 KB/s'); + expect(TransferRateFormatter.format(1536).text, '1.5 KB/s'); + expect(TransferRateFormatter.format(1280).text, '1.25 KB/s'); + expect(TransferRateFormatter.format(10 * 1024 * 1024).text, '10 MB/s'); + expect(TransferRateFormatter.format(2 * 1024 * 1024 * 1024).text, '2 GB/s'); + }); +} diff --git a/ui/flutter/test/core/window/app_window_launcher_test.dart b/ui/flutter/test/core/window/app_window_launcher_test.dart new file mode 100644 index 00000000..a9da92d1 --- /dev/null +++ b/ui/flutter/test/core/window/app_window_launcher_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/core/window/app_window_launcher.dart'; +import 'package:gopeed/core/window/window_capability_transport.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const multiWindowChannel = MethodChannel('mixin.one/desktop_multi_window'); + const windowChannels = MethodChannel('mixin.one/desktop_multi_window/channels'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + messenger.setMockMethodCallHandler(windowChannels, (call) async => null); + }); + + tearDown(() async { + await AppWindowCapabilityHost.instance.stop(); + messenger.setMockMethodCallHandler(multiWindowChannel, null); + messenger.setMockMethodCallHandler(windowChannels, null); + }); + + test('create-task window stays hidden until child bootstrap is ready', () async { + final calls = []; + messenger.setMockMethodCallHandler(multiWindowChannel, (call) async { + calls.add(call); + if (call.method == 'createWindow') return 'create-window-id'; + return null; + }); + messenger.setMockMethodCallHandler(windowChannels, (call) async { + calls.add(call); + return null; + }); + + final opened = await AppWindowLauncher.openCreateTaskWindow( + createTask: CreateTask(req: Request(url: 'https://example.com/file.zip')), + ); + + expect(opened, isTrue); + final createCall = calls.firstWhere((call) => call.method == 'createWindow'); + final configuration = Map.from(createCall.arguments as Map); + expect(configuration['hiddenAtLaunch'], isTrue); + expect( + calls.where((call) => call.method == 'window_show' && (call.arguments as Map)['windowId'] == 'create-window-id'), + isEmpty, + ); + }); + + test('create-task window failure reports fallback availability', () async { + messenger.setMockMethodCallHandler(multiWindowChannel, (call) async { + if (call.method == 'createWindow') { + throw PlatformException(code: 'create_failed'); + } + return null; + }); + + expect(await AppWindowLauncher.openCreateTaskWindow(), isFalse); + }); +} diff --git a/ui/flutter/test/core/window/app_window_payload_test.dart b/ui/flutter/test/core/window/app_window_payload_test.dart new file mode 100644 index 00000000..9b71c2b3 --- /dev/null +++ b/ui/flutter/test/core/window/app_window_payload_test.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/window/app_window_bootstrap.dart'; +import 'package:gopeed/core/window/app_window_payload.dart'; +import 'package:gopeed/l10n/l10n.dart'; + +void main() { + test('create-task child window has a localized native title', () { + expect( + AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')), + 'Create Task - Gopeed', + ); + expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed'); + }); + + test('create-task payload only carries initial task data', () { + final raw = AppWindowPayload.createTask( + createTask: { + 'urls': ['https://example.com/file.zip'], + }, + ).toRaw(); + final decoded = jsonDecode(raw) as Map; + + expect(decoded.keys, containsAll(['type', 'createTask'])); + expect(decoded.keys, hasLength(2)); + expect(decoded, isNot(contains('apiToken'))); + expect(decoded, isNot(contains('theme'))); + expect(decoded, isNot(contains('createHistory'))); + }); + + test('create-task payload preserves nested browser request parameters', () { + final payload = AppWindowPayload.createTask( + createTask: { + 'req': { + 'rawUrl': 'https://page.example/download', + 'url': 'https://cdn.example/archive.zip', + 'extra': { + 'method': 'GET', + 'header': {'Cookie': 'session=abc', 'Referer': 'https://page.example/', 'Authorization': 'Bearer token'}, + 'body': '', + }, + 'labels': {'source': 'browser-extension'}, + 'skipVerifyCert': true, + }, + 'opts': {'name': 'archive.zip', 'path': 'D:/Downloads', 'selectFiles': [], 'extra': null}, + }, + ); + + final decoded = AppWindowPayload.fromRaw(payload.toRaw()).createTask; + + expect(decoded?['req'], payload.createTask?['req']); + expect(decoded?['opts'], payload.createTask?['opts']); + }); +} diff --git a/ui/flutter/test/features/auth/presentation/login_page_test.dart b/ui/flutter/test/features/auth/presentation/login_page_test.dart new file mode 100644 index 00000000..82d3b3dd --- /dev/null +++ b/ui/flutter/test/features/auth/presentation/login_page_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/auth/presentation/pages/login_page.dart'; +import 'package:gopeed/l10n/app_localizations.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + Widget buildApp() { + return ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AppComponentThemes(child: LoginPage()), + ), + ); + } + + Future setViewport(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + } + + testWidgets('uses a compact responsive card on mobile', (tester) async { + await setViewport(tester, const Size(390, 844)); + + expect(find.byKey(const ValueKey('login-compact-card')), findsOneWidget); + expect(find.byKey(const ValueKey('login-desktop-card')), findsNothing); + expect(find.byType(SingleChildScrollView), findsNothing); + expect(find.byType(FittedBox), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('uses a split card on desktop', (tester) async { + await setViewport(tester, const Size(1200, 800)); + + expect(find.byKey(const ValueKey('login-desktop-card')), findsOneWidget); + expect(find.byKey(const ValueKey('login-compact-card')), findsNothing); + expect(find.byType(SingleChildScrollView), findsNothing); + expect(find.byType(FittedBox), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('fits a short narrow viewport without overflow or scrolling', (tester) async { + await setViewport(tester, const Size(320, 480)); + + expect(find.byType(SingleChildScrollView), findsNothing); + expect(find.byKey(const ValueKey('login-compact-card')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('exposes browser-compatible username and password autofill fields', (tester) async { + await setViewport(tester, const Size(390, 844)); + + final username = tester.widget(find.byKey(const ValueKey('login-username-field'))); + final password = tester.widget(find.byKey(const ValueKey('login-password-field'))); + + final autofillGroup = tester.widget(find.byType(AutofillGroup)); + expect(autofillGroup.onDisposeAction, AutofillContextAction.cancel); + expect(username.autofillHints, contains(AutofillHints.username)); + expect(username.autofocus, isTrue); + expect(username.textInputAction, TextInputAction.done); + expect(password.autofillHints, contains(AutofillHints.password)); + expect(password.textInputAction, TextInputAction.done); + expect(password.obscureText, isTrue); + expect(password.features.whereType(), hasLength(1)); + }); + + testWidgets('uses a compact centered neutral login action', (tester) async { + await setViewport(tester, const Size(1200, 800)); + + final buttonContainer = find.byKey(const ValueKey('login-submit-button-container')); + final button = tester.widget(find.byKey(const ValueKey('login-submit-button'))); + expect(tester.getSize(buttonContainer), const Size(148, 40)); + expect(button.alignment, Alignment.center); + expect(button.variant, AppLoadingButtonVariant.primary); + }); + + testWidgets('validates required credentials before submitting', (tester) async { + await setViewport(tester, const Size(390, 844)); + + await tester.tap(find.byKey(const ValueKey('login-submit-button'))); + await tester.pump(); + + expect(find.text('Please enter your username'), findsOneWidget); + expect(find.text('Please enter your password'), findsOneWidget); + }); +} diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_icon_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_icon_test.dart new file mode 100644 index 00000000..6b3baf68 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_icon_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/api/model/store_extension.dart'; +import 'package:gopeed/features/extensions/application/extensions_controller.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_icon.dart'; +import 'package:path/path.dart' as path; + +void main() { + test('installed extension uses its local icon instead of the store icon', () { + final item = ExtensionListItem(installed: _installed(), store: _store()); + + final source = resolveExtensionIconSource(item, web: false, storageDirectory: '/app-data'); + + expect(source.kind, ExtensionIconSourceKind.file); + expect(source.location, path.join('/app-data', 'extensions', 'gopeed@test', 'images', 'icon.png')); + }); + + test('development extension uses its development directory', () { + final item = ExtensionListItem( + installed: _installed(devMode: true, devPath: '/extension-source'), + store: _store(), + ); + + final source = resolveExtensionIconSource(item, web: false, storageDirectory: '/app-data'); + + expect(source.kind, ExtensionIconSourceKind.file); + expect(source.location, path.join('/extension-source', 'images', 'icon.png')); + }); + + test('web uses the local Gopeed extension file endpoint', () { + final item = ExtensionListItem(installed: _installed(), store: _store()); + + final source = resolveExtensionIconSource(item, web: true); + + expect(source.kind, ExtensionIconSourceKind.network); + expect(source.location, '/fs/extensions/gopeed@test/images/icon.png'); + }); + + test('uninstalled market extension keeps using its remote icon', () { + final source = resolveExtensionIconSource(ExtensionListItem(store: _store()), web: false); + + expect(source.kind, ExtensionIconSourceKind.network); + expect(source.location, 'https://cdn.example.com/icon.png'); + }); + + test('installed extension without an icon uses the bundled fallback', () { + final source = resolveExtensionIconSource( + ExtensionListItem( + installed: _installed(icon: ''), + store: _store(), + ), + web: false, + storageDirectory: '/app-data', + ); + + expect(source.kind, ExtensionIconSourceKind.asset); + expect(source.location, 'assets/extension/default_icon.png'); + }); +} + +Extension _installed({String icon = 'images/icon.png', bool devMode = false, String devPath = ''}) { + return Extension( + identity: 'gopeed@test', + name: 'test', + author: 'gopeed', + title: 'Test', + description: 'Test extension', + icon: icon, + version: '1.0.0', + homepage: '', + repository: Repository(url: 'https://github.com/GopeedLab/test', directory: ''), + disabled: false, + devMode: devMode, + devPath: devPath, + ); +} + +StoreExtension _store() { + return StoreExtension( + id: 'gopeed@test', + repoFullName: 'GopeedLab/test', + repoUrl: 'https://github.com/GopeedLab/test', + name: 'test', + author: 'gopeed', + title: 'Test', + description: 'Test extension', + icon: 'https://cdn.example.com/icon.png', + version: '1.0.0', + homepage: null, + readme: null, + installCount: 1, + stars: 1, + topics: const [], + ); +} diff --git a/ui/flutter/test/features/settings/application/settings_controller_test.dart b/ui/flutter/test/features/settings/application/settings_controller_test.dart new file mode 100644 index 00000000..7fc1bbcc --- /dev/null +++ b/ui/flutter/test/features/settings/application/settings_controller_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/capabilities/gopeed_capability.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/features/settings/application/settings_controller.dart'; + +void main() { + test('settings reload backend config after the page releases its provider', () async { + var backendDirectory = 'D:/Initial'; + var getConfigCalls = 0; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) { + getConfigCalls++; + return DownloaderConfig(downloadDir: backendDirectory); + }); + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))), + appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new), + ], + ); + addTearDown(container.dispose); + + final firstListener = container.listen(settingsControllerProvider, (_, _) {}, fireImmediately: true); + expect((await container.read(settingsControllerProvider.future)).config.downloadDir, 'D:/Initial'); + firstListener.close(); + await container.pump(); + + backendDirectory = 'E:/Created task'; + final secondListener = container.listen(settingsControllerProvider, (_, _) {}, fireImmediately: true); + addTearDown(secondListener.close); + expect((await container.read(settingsControllerProvider.future)).config.downloadDir, 'E:/Created task'); + expect(getConfigCalls, 2); + }); +} + +class _TestRuntimeController extends AppRuntimeController { + @override + Future build() async => AppRuntimeState( + startConfig: StartConfig(), + runningPort: 0, + downloaderConfig: DownloaderConfig(downloadDir: 'C:/Stale runtime snapshot'), + ); +} diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart new file mode 100644 index 00000000..aadca722 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -0,0 +1,358 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/widgets.dart' as flutter show Row; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/api/model/options.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/capabilities/gopeed_capability.dart'; +import 'package:gopeed/core/capabilities/storage_capability.dart'; +import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart'; +import 'package:gopeed/shared/services/download_directory_picker.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_design_tokens.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_tooltip.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('browser extension request parameters populate the form and survive submission', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + final androidLocations = {'application': 'D:/Downloads', 'downloads': 'G:/Manually selected'}; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => androidLocations; + DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; + addTearDown(() { + DownloadDirectoryPicker.debugPlatformOverride = null; + DownloadDirectoryPicker.debugAndroidLocationsLoader = null; + DownloadDirectoryPicker.debugDownloadsPreparer = null; + }); + + CreateTask? submitted; + final config = DownloaderConfig(downloadDir: 'C:/Downloads') + ..extra.defaultDirectDownload = true + ..extra.downloadCategories = [ + DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), + DownloadCategory(name: 'Archives', path: 'F:/Archives'), + DownloadCategory(name: 'Long video category', path: 'G:/Videos'), + DownloadCategory(name: 'Long document category', path: 'H:/Documents'), + ] + ..protocolConfig.http.connections = 8; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-task'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + final initialTask = CreateTask( + req: Request( + rawUrl: 'https://page.example/download', + url: 'https://cdn.example/archive.zip', + extra: ReqExtraHttp( + method: 'POST', + header: const { + 'User-Agent': 'Browser UA', + 'Cookie': 'session=abc', + 'Referer': 'https://page.example/', + 'Authorization': 'Bearer token', + }, + body: 'request-body', + ).toJson(), + labels: const {'source': 'browser-extension'}, + proxy: RequestProxy( + mode: RequestProxyMode.custom, + scheme: 'socks5', + host: '127.0.0.1:7890', + usr: 'proxy-user', + pwd: 'proxy-password', + ), + skipVerifyCert: true, + ), + opts: Options( + name: 'archive.zip', + path: 'D:/Downloads', + extra: OptsExtraHttp( + connections: 12, + autoTorrent: true, + deleteTorrentAfterDownload: true, + autoExtract: true, + archivePassword: 'archive-password', + deleteAfterExtract: true, + ).toJson(), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/create', + builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(_fieldText(tester, 'create-task-http-header-name-0'), 'User-Agent'); + expect(_fieldText(tester, 'create-task-http-header-value-0'), 'Browser UA'); + expect(_fieldText(tester, 'create-task-http-header-name-3'), 'Authorization'); + expect(_fieldText(tester, 'create-task-http-header-value-3'), 'Bearer token'); + expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); + expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); + expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); + expect(_fieldText(tester, 'create-task-proxy-password'), 'proxy-password'); + expect(_fieldText(tester, 'create-task-archive-password'), 'archive-password'); + expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-auto-torrent')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-delete-torrent')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-delete-after-extract')), findsOneWidget); + + expect(find.text('Archives'), findsNWidgets(2)); + expect(find.text('Quick folders'), findsNothing); + expect(find.byKey(const ValueKey('create-task-category-0')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-1')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-2')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-category-3')), findsOneWidget); + final shortcutRow = find.byKey(const ValueKey('create-task-directory-shortcuts')); + expect(tester.getSize(shortcutRow).height, 28); + final rememberDirectory = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); + final directDownloadCheckbox = find.byKey(const ValueKey('create-task-direct-download-checkbox')); + final rememberDirectoryRow = find.byKey(const ValueKey('create-task-remember-download-directory')); + final directoryComponent = find.byKey(const ValueKey('create-task-directory-component')); + final directoryOptionsRow = find.byKey(const ValueKey('create-task-directory-options-row')); + final directoryOptionsLayout = find.byKey(const ValueKey('create-task-directory-options-layout')); + final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker')); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect(tester.widget(rememberDirectory).size, isNull); + expect(tester.getSize(rememberDirectory), const Size.square(AppDesignTokens.checkboxSize)); + expect(tester.getCenter(rememberDirectory).dx, closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5)); + final rememberDirectoryLabel = tester.widget( + find.byKey(const ValueKey('create-task-remember-download-directory-label')), + ); + final directDownloadLabel = tester.widget(find.byKey(const ValueKey('create-task-direct-download-label'))); + expect(rememberDirectoryLabel.style, directDownloadLabel.style); + expect(rememberDirectoryLabel.maxLines, directDownloadLabel.maxLines); + expect(rememberDirectoryLabel.overflow, directDownloadLabel.overflow); + expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); + expect(tester.widget(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween); + expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5)); + expect(tester.getTopRight(directoryOptionsRow).dx, closeTo(tester.getTopRight(directoryComponent).dx, 0.5)); + expect( + tester.getTopRight(find.byKey(const ValueKey('create-task-category-3'))).dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + + tester.view.physicalSize = const Size(520, 720); + await tester.pumpAndSettle(); + final shortcutContent = find.byKey(const ValueKey('create-task-directory-shortcuts-content')); + expect(tester.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy)); + expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5)); + expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width)); + final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable)); + expect(tester.state(shortcutScrollable).position.maxScrollExtent, greaterThan(0)); + + tester.view.physicalSize = const Size(719, 720); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))), + isA(), + ); + + tester.view.physicalSize = const Size(720, 720); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))).mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + + tester.view.physicalSize = const Size(1024, 720); + await tester.pumpAndSettle(); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); + + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); + await tester.pump(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect( + find.descendant( + of: find.byKey(const ValueKey('create-task-category-3')), + matching: find.byIcon(Icons.folder_outlined), + ), + findsOneWidget, + ); + + androidLocations['downloads'] = 'C:/Downloads'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + + androidLocations['downloads'] = 'G:/Manually selected'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); + + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + final request = submitted?.req; + expect(request, isNotNull); + expect(request?.rawUrl, initialTask.req?.rawUrl); + expect(request?.labels, initialTask.req?.labels); + expect(request?.proxy?.mode, RequestProxyMode.custom); + expect(request?.proxy?.scheme, 'socks5'); + expect(request?.proxy?.host, '127.0.0.1:7890'); + expect(request?.proxy?.usr, 'proxy-user'); + expect(request?.proxy?.pwd, 'proxy-password'); + expect(request?.skipVerifyCert, isTrue); + expect(ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), initialTask.req?.extra); + expect(submitted?.opts?.path, 'G:/Manually selected'); + final options = OptsExtraHttp.fromJson(submitted?.opts?.extra! as Map); + expect(options.connections, 12); + expect(options.autoTorrent, isTrue); + expect(options.deleteTorrentAfterDownload, isTrue); + expect(options.autoExtract, isTrue); + expect(options.archivePassword, 'archive-password'); + expect(options.deleteAfterExtract, isTrue); + expect(submitted?.opts?.asDefaultPath, isTrue); + }); + + testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final histories = [ + 'https://example.com/alpha.zip', + 'https://example.com/beta.iso', + 'magnet:?xt=urn:btih:gamma', + ]; + String? removedHistory; + var cleared = false; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(StorageMethods.getCreateHistory, (_) => List.unmodifiable(histories)) + ..bind(StorageMethods.removeCreateHistory, (value) { + removedHistory = value; + histories.remove(value); + return const RpcUnit(); + }) + ..bind(StorageMethods.clearCreateHistory, (_) { + cleared = true; + histories.clear(); + return const RpcUnit(); + }); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.history)); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, + tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, + ); + final alphaItem = find.byKey(const ValueKey('create-history-item-https://example.com/alpha.zip')); + final betaItem = find.byKey(const ValueKey('create-history-item-https://example.com/beta.iso')); + final gammaItem = find.byKey(const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma')); + expect(alphaItem, findsOneWidget); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + final clearAction = find.byKey(const ValueKey('create-history-clear')); + final closeAction = find.byKey(const ValueKey('create-history-close')); + expect( + tester.widget(find.ancestor(of: clearAction, matching: find.byType(AppTooltip))).message, + 'Clear History', + ); + expect( + tester.widget(find.ancestor(of: closeAction, matching: find.byType(AppTooltip))).message, + 'Close', + ); + final historyText = tester.widget(find.descendant(of: alphaItem, matching: find.byType(Text))); + expect(historyText.data, contains('\u200B')); + expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); + + final filter = find.byKey(const ValueKey('create-history-filter')); + await tester.enterText(filter, 'beta'); + await tester.pump(); + expect(alphaItem, findsNothing); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsNothing); + + await tester.tap(find.byKey(const ValueKey('create-history-delete-https://example.com/beta.iso'))); + await tester.pumpAndSettle(); + expect(removedHistory, 'https://example.com/beta.iso'); + expect(find.text('No History Found'), findsOneWidget); + + await tester.enterText(filter, ''); + await tester.pump(); + expect(alphaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('create-history-clear'))); + await tester.pumpAndSettle(); + expect(cleared, isTrue); + expect(find.text('No history'), findsOneWidget); + expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); + expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); + }); +} + +String _fieldText(WidgetTester tester, String key) { + final editable = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(EditableText)); + return tester.widget(editable).controller.text; +} diff --git a/ui/flutter/test/features/tasks/presentation/task_record_localizations_test.dart b/ui/flutter/test/features/tasks/presentation/task_record_localizations_test.dart new file mode 100644 index 00000000..6814be20 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/task_record_localizations_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/domain/task_record.dart'; +import 'package:gopeed/features/tasks/presentation/task_record_localizations.dart'; +import 'package:gopeed/l10n/app_localizations_en.dart'; + +void main() { + test('remaining time and download duration share unit thresholds and rounding', () { + final l10n = AppLocalizationsEn(); + final cases = <(int, String, String)>[ + (59, '59 seconds remaining', '59 seconds'), + (60, '1 minute remaining', '1 minute'), + (61, '2 minutes remaining', '2 minutes'), + (3599, '60 minutes remaining', '60 minutes'), + (3600, '1 hour remaining', '1 hour'), + (3723, '2 hours remaining', '2 hours'), + ]; + + for (final (seconds, remaining, duration) in cases) { + final runningTask = TaskRecord( + id: 'running-$seconds', + name: 'archive.zip', + status: TaskStatus.downloading, + downloaded: '1 MB', + url: 'https://example.com/archive.zip', + storagePath: '/downloads/archive.zip', + files: const [], + uploading: false, + remainingSeconds: seconds, + ); + final completedTask = TaskRecord( + id: 'completed-$seconds', + name: 'archive.zip', + status: TaskStatus.completed, + downloaded: '1 MB', + url: 'https://example.com/archive.zip', + storagePath: '/downloads/archive.zip', + files: const [], + uploading: false, + downloadDuration: Duration(seconds: seconds), + ); + + expect(runningTask.localizedRemaining(l10n), remaining); + expect(completedTask.localizedDownloadDuration(l10n), duration); + expect(formatTaskDuration(l10n, Duration(seconds: seconds)), duration); + } + }); +} diff --git a/ui/flutter/test/package_info_test.dart b/ui/flutter/test/package_info_test.dart new file mode 100644 index 00000000..69b6d2d8 --- /dev/null +++ b/ui/flutter/test/package_info_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/util/package_info.dart'; + +void main() { + group('normalizeAppVersion', () { + test('treats a four-part platform version as a beta version', () { + expect(normalizeAppVersion('2.0.0.1'), '2.0.0-beta.1'); + expect(normalizeAppVersion('2.0.0.12'), '2.0.0-beta.12'); + }); + + test('keeps stable and semantic prerelease versions unchanged', () { + expect(normalizeAppVersion('2.0.0'), '2.0.0'); + expect(normalizeAppVersion('2.0.0-beta.1'), '2.0.0-beta.1'); + }); + }); +} diff --git a/ui/flutter/test/shared/theme/app_component_themes_test.dart b/ui/flutter/test/shared/theme/app_component_themes_test.dart new file mode 100644 index 00000000..9080b1e2 --- /dev/null +++ b/ui/flutter/test/shared/theme/app_component_themes_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_design_tokens.dart'; +import 'package:gopeed/shared/theme/app_palette.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('provides the standard checkbox metrics', (tester) async { + late shad.CheckboxTheme checkboxTheme; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) { + checkboxTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(checkboxTheme.size, AppDesignTokens.checkboxSize); + expect(checkboxTheme.gap, AppDesignTokens.checkboxLabelGap); + }); + + testWidgets('uses a high-contrast checkbox border in the dark theme', (tester) async { + late shad.CheckboxTheme checkboxTheme; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: AppComponentThemes( + child: Builder( + builder: (context) { + checkboxTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(checkboxTheme.borderColor, AppPalette.dark.textMuted); + expect(_contrastRatio(checkboxTheme.borderColor!, AppPalette.dark.bg), greaterThanOrEqualTo(3)); + }); + + testWidgets('uses distinct borderless secondary button fills in the light theme', (tester) async { + late shad.SecondaryButtonTheme buttonTheme; + late BuildContext themedContext; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) { + themedContext = context; + buttonTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + final decoration = + buttonTheme.decoration!(themedContext, const {}, const BoxDecoration()) as BoxDecoration; + final hoveredDecoration = + buttonTheme.decoration!(themedContext, const {WidgetState.hovered}, const BoxDecoration()) + as BoxDecoration; + final disabledDecoration = + buttonTheme.decoration!(themedContext, const {WidgetState.disabled}, const BoxDecoration()) + as BoxDecoration; + expect(decoration.border, isNull); + expect(hoveredDecoration.border, isNull); + expect(disabledDecoration.border, isNull); + expect(decoration.color, isNot(AppPalette.light.surfaceSoft)); + expect(hoveredDecoration.color, isNot(decoration.color)); + }); +} + +double _contrastRatio(Color first, Color second) { + final firstLuminance = first.computeLuminance(); + final secondLuminance = second.computeLuminance(); + final lighter = firstLuminance > secondLuminance ? firstLuminance : secondLuminance; + final darker = firstLuminance > secondLuminance ? secondLuminance : firstLuminance; + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/ui/flutter/test/shared/widgets/app_loading_button_test.dart b/ui/flutter/test/shared/widgets/app_loading_button_test.dart new file mode 100644 index 00000000..a29fb401 --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_loading_button_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_palette.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('loading buttons replace only the icon and preserve their color family', (tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: AppComponentThemes( + child: Column( + children: [ + AppLoadingButton( + key: const ValueKey('secondary-loading-button'), + onPressed: () {}, + loading: true, + icon: const Icon(shad.LucideIcons.refreshCw), + child: const Text('Check for Updates'), + ), + AppLoadingButton( + key: const ValueKey('brand-loading-button'), + onPressed: () {}, + loading: true, + variant: AppLoadingButtonVariant.brand, + icon: const Icon(shad.LucideIcons.download), + child: const Text('Update'), + ), + AppLoadingButton( + key: const ValueKey('primary-loading-button'), + onPressed: () {}, + loading: true, + variant: AppLoadingButtonVariant.primary, + icon: const Icon(shad.LucideIcons.save), + child: const Text('Save'), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + final secondary = find.byKey(const ValueKey('secondary-loading-button')); + final brand = find.byKey(const ValueKey('brand-loading-button')); + final primary = find.byKey(const ValueKey('primary-loading-button')); + expect(find.descendant(of: secondary, matching: find.text('Check for Updates')), findsOneWidget); + expect(find.descendant(of: brand, matching: find.text('Update')), findsOneWidget); + expect(find.descendant(of: primary, matching: find.text('Save')), findsOneWidget); + expect(find.descendant(of: secondary, matching: find.byIcon(shad.LucideIcons.refreshCw)), findsNothing); + expect(find.descendant(of: brand, matching: find.byIcon(shad.LucideIcons.download)), findsNothing); + expect(find.descendant(of: primary, matching: find.byIcon(shad.LucideIcons.save)), findsNothing); + + final secondaryProgress = tester.widget( + find.descendant(of: secondary, matching: find.byType(shad.CircularProgressIndicator)), + ); + final brandProgress = tester.widget( + find.descendant(of: brand, matching: find.byType(shad.CircularProgressIndicator)), + ); + final primaryProgress = tester.widget( + find.descendant(of: primary, matching: find.byType(shad.CircularProgressIndicator)), + ); + expect( + tester.widget(find.descendant(of: primary, matching: find.byType(shad.Clickable))).enabled, + isFalse, + ); + expect(secondaryProgress.color, AppPalette.dark.textMuted); + expect(secondaryProgress.color, isNot(AppPalette.dark.brand)); + expect(brandProgress.color, AppPalette.dark.brandForeground); + expect(primaryProgress.color, AppPalette.dark.primaryActionForeground); + + final secondaryDecoration = + tester + .widget(find.descendant(of: secondary, matching: find.byType(shad.Clickable))) + .decoration! + .resolve({WidgetState.disabled}) + as BoxDecoration; + final brandDecoration = + tester + .widget(find.descendant(of: brand, matching: find.byType(shad.Clickable))) + .decoration! + .resolve({WidgetState.disabled}) + as BoxDecoration; + expect(secondaryDecoration.color, AppPalette.dark.surfaceSoft); + expect(brandDecoration.color, AppPalette.dark.brand); + }); + + testWidgets('text-only loading buttons replace their label with a centered loader', (tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: AppComponentThemes( + child: Center( + child: SizedBox( + key: const ValueKey('text-only-loading-bounds'), + width: 148, + height: 40, + child: AppLoadingButton( + key: const ValueKey('text-only-loading-button'), + onPressed: () {}, + loading: true, + alignment: Alignment.center, + variant: AppLoadingButtonVariant.primary, + child: const Text('Login'), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final bounds = tester.getRect(find.byKey(const ValueKey('text-only-loading-bounds'))); + final loader = find.descendant( + of: find.byKey(const ValueKey('text-only-loading-button')), + matching: find.byType(shad.CircularProgressIndicator), + ); + expect(find.text('Login'), findsNothing); + expect(loader, findsOneWidget); + expect(tester.getRect(loader).center, bounds.center); + }); +} diff --git a/ui/flutter/test/shared/widgets/app_tooltip_test.dart b/ui/flutter/test/shared/widgets/app_tooltip_test.dart new file mode 100644 index 00000000..e9a49a1a --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_tooltip_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' show MaterialApp, Tooltip; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/widgets/app_tooltip.dart'; + +void main() { + testWidgets('AppTooltip delegates product tooltips to Flutter Tooltip', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: AppTooltip(message: 'Details', child: SizedBox.square(dimension: 24)), + ), + ); + + expect(tester.widget(find.byType(Tooltip)).message, 'Details'); + }); + + test('product source does not instantiate Tooltip directly', () { + final violations = []; + final tooltipConstructor = RegExp(r'\b(?:\w+\.)?Tooltip\s*\('); + + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart') || entity.path.endsWith('app_tooltip.dart')) { + continue; + } + final lines = entity.readAsLinesSync(); + for (var index = 0; index < lines.length; index++) { + if (tooltipConstructor.hasMatch(lines[index])) { + violations.add('${entity.path}:${index + 1}'); + } + } + } + + expect( + violations, + isEmpty, + reason: + 'Use AppTooltip so product tooltip styling stays centralized. Direct Tooltip usage found at: ' + '${violations.join(', ')}', + ); + }); +} diff --git a/ui/flutter/test/updater_test.dart b/ui/flutter/test/updater_test.dart new file mode 100644 index 00000000..6ec81862 --- /dev/null +++ b/ui/flutter/test/updater_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/util/arch/arch.dart'; +import 'package:gopeed/util/github_mirror.dart'; +import 'package:gopeed/util/package_info.dart' as app_package; +import 'package:gopeed/util/updater.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + + setUpAll(() { + app_package.packageInfo = PackageInfo( + appName: 'Gopeed', + packageName: 'com.gopeed.Gopeed', + version: '1.7.0', + buildNumber: '1', + ); + }); + + group('version comparison', () { + test('uses semantic version precedence', () { + expect(isNewerVersion('1.10.0', '1.9.9'), isTrue); + expect(isNewerVersion('2.0.0-beta.2', '2.0.0-beta.1'), isTrue); + expect(isNewerVersion('2.0.0-rc.1', '2.0.0-beta.2'), isTrue); + expect(isNewerVersion('2.0.0', '2.0.0-rc.1'), isTrue); + expect(isNewerVersion('1.2.3', '1.2.3'), isFalse); + expect(isNewerVersion('1.2.2', '1.2.3'), isFalse); + expect(isNewerVersion('invalid', '1.2.3'), isFalse); + }); + }); + + group('release selection', () { + Map release(String version, {bool prerelease = false, bool draft = false}) => { + 'tag_name': 'v$version', + 'prerelease': prerelease, + 'draft': draft, + 'body': '$version notes', + 'html_url': 'https://example.com/$version', + }; + + test('stable builds ignore preview releases', () { + final selected = selectUpdateRelease([release('2.1.0-beta.1', prerelease: true), release('2.0.1')], '2.0.0'); + + expect(selected?.version, '2.0.1'); + }); + + test('preview builds advance within the same preview line', () { + final selected = selectUpdateRelease([ + release('2.0.0-beta.2', prerelease: true), + release('1.9.9'), + release('2.1.0-beta.1', prerelease: true), + ], '2.0.0-beta.1'); + + expect(selected?.version, '2.0.0-beta.2'); + }); + + test('four-part platform beta selects the next GitHub prerelease and matching asset', () { + final currentVersion = app_package.normalizeAppVersion('2.0.0.1'); + final selected = selectUpdateRelease([release('2.0.0-beta.2', prerelease: true)], currentVersion); + + expect(selected?.version, '2.0.0-beta.2'); + expect( + updateAssetName(selected!.version, channel: UpdateChannel.macosDmg, architecture: Architecture.arm64), + 'Gopeed-v2.0.0-beta.2-macos-arm64.dmg', + ); + }); + + test('preview builds graduate to the final stable release', () { + final selected = selectUpdateRelease([ + release('2.0.0-beta.2', prerelease: true), + release('2.0.0'), + ], '2.0.0-beta.1'); + + expect(selected?.version, '2.0.0'); + }); + + test('selects the greatest eligible version and ignores invalid releases', () { + final selected = selectUpdateRelease([ + release('2.0.0-beta.3', prerelease: true), + release('2.0.0-beta.4', prerelease: true, draft: true), + {'tag_name': 'not-a-version'}, + release('2.0.0-beta.2', prerelease: true), + ], '2.0.0-beta.1'); + + expect(selected?.version, '2.0.0-beta.3'); + }); + }); + + group('release assets', () { + test('uses the release naming convention for desktop packages', () { + expect( + updateAssetName('1.8.0', channel: UpdateChannel.windowsPortable, architecture: Architecture.x64), + 'Gopeed-v1.8.0-windows-amd64-portable.zip', + ); + expect( + updateAssetName('1.8.0', channel: UpdateChannel.macosDmg, architecture: Architecture.arm64), + 'Gopeed-v1.8.0-macos-arm64.dmg', + ); + expect( + updateAssetName('1.8.0', channel: UpdateChannel.linuxDeb, architecture: Architecture.ia32), + 'Gopeed-v1.8.0-linux-386.deb', + ); + }); + + test('maps Android ABI names', () { + expect( + updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm), + 'Gopeed-v1.8.0-android-armeabi-v7a.apk', + ); + expect( + updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm64), + 'Gopeed-v1.8.0-android-arm64-v8a.apk', + ); + }); + }); + + test('selects the matching bilingual release notes', () { + const notes = '# Release notes\n\n- English change\n\n# 更新日志\n\n- 中文更新'; + expect(localizedReleaseNotes(notes, 'en'), '# Release notes\n\n- English change'); + expect(localizedReleaseNotes(notes, 'zh-CN'), '# 更新日志\n\n- 中文更新'); + }); + + test('builds enabled GitHub proxy URLs and ignores deleted mirrors', () { + final config = ExtraConfigGithubMirror( + mirrors: [ + GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://mirror.example'), + GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://deleted.example', isDeleted: true), + ], + ); + expect( + githubMirrorUrls( + 'https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip', + MirrorType.githubRelease, + config: config, + ), + ['https://mirror.example/https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip'], + ); + }); + + testWidgets('update dialog shows beta versions without tag prefixes', (tester) async { + tester.view.physicalSize = const Size(390, 760); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + app_package.packageInfo = PackageInfo( + appName: 'Gopeed', + packageName: 'com.gopeed.Gopeed', + version: '2.0.0.1', + buildNumber: '1', + ); + addTearDown(() { + app_package.packageInfo = PackageInfo( + appName: 'Gopeed', + packageName: 'com.gopeed.Gopeed', + version: '1.7.0', + buildNumber: '1', + ); + }); + + const version = VersionInfo( + version: '2.0.0-beta.2', + changeLog: '# Release notes\n\n- Faster downloads\n\n# 更新日志\n\n- 下载更快', + releaseUrl: 'https://example.com/release', + ); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => shad.SecondaryButton( + onPressed: () => showAppUpdateDialog(context, versionInfo: version, onUpdate: (_, _) async {}), + child: const Text('Open update'), + ), + ), + ), + ); + await tester.tap(find.text('Open update')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('app-update-dialog')), findsOneWidget); + expect(find.text('A new version is available'), findsOneWidget); + expect(find.text('2.0.0-beta.1 → 2.0.0-beta.2'), findsOneWidget); + expect(find.textContaining('v2.0.0'), findsNothing); + expect(find.text('Faster downloads'), findsOneWidget); + expect(find.text('下载更快'), findsNothing); + expect(find.text('Later'), findsOneWidget); + expect(find.text('Update Now'), findsOneWidget); + final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog'))); + expect(dialog.width, lessThan(320)); + expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01)); + expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01)); + expect(tester.takeException(), isNull); + }); +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index ae40864a..d985f1ca 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1,12 +1,4798 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/material.dart' show Icons, Scrollbar; +import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton; +import 'package:flutter/services.dart' show MethodChannel, SystemChannels; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import 'package:window_manager/window_manager.dart'; +import 'package:gopeed/app/app.dart'; +import 'package:gopeed/app/application/app_appearance_controller.dart'; +import 'package:gopeed/app/application/app_deep_link_controller.dart'; +import 'package:gopeed/app/application/app_notification_controller.dart'; +import 'package:gopeed/app/application/app_platform_controller.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/api/model/extension.dart' as api_extension; +import 'package:gopeed/api/model/meta.dart'; +import 'package:gopeed/api/model/options.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resource.dart'; +import 'package:gopeed/api/model/store_extension.dart'; +import 'package:gopeed/api/model/task.dart' as api_task; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/icons/gopeed_icons.dart'; +import 'package:gopeed/core/window/app_window_chrome.dart'; +import 'package:gopeed/core/window/app_window_frame.dart'; +import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart'; +import 'package:gopeed/features/extensions/application/extensions_controller.dart'; +import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart'; +import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart'; +import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart'; +import 'package:gopeed/features/tasks/application/pending_update_task.dart'; +import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart'; +import 'package:gopeed/features/tasks/application/task_runtime_status_provider.dart'; +import 'package:gopeed/features/tasks/application/task_stats_provider.dart'; +import 'package:gopeed/features/tasks/application/tasks_controller.dart'; +import 'package:gopeed/features/settings/application/settings_controller.dart'; +import 'package:gopeed/features/settings/presentation/pages/settings_page.dart'; +import 'package:gopeed/features/settings/presentation/widgets/download_categories_setting.dart'; +import 'package:gopeed/features/settings/presentation/widgets/settings_item.dart'; +import 'package:gopeed/features/settings/presentation/widgets/settings_language_select.dart'; +import 'package:gopeed/features/settings/presentation/widgets/settings_list_editor.dart'; +import 'package:gopeed/features/tasks/domain/task_record.dart'; +import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/speed_monitor_card.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_batch_selection_builder.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_file_tree.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_progress_bar.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_context_menu.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/pending_update_dialog.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_statistics/piece_map.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_statistics/peer_table.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_statistics/task_statistics_tab.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/task_update_url_dialog.dart'; +import 'package:gopeed/api/model/task_stats.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_palette.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_design_tokens.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/theme/app_theme_color.dart'; +import 'package:gopeed/shared/services/download_directory_picker.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:gopeed/shared/widgets/app_choice_segmented_control.dart'; +import 'package:gopeed/shared/widgets/app_http_headers_editor.dart'; +import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; +import 'package:gopeed/shared/widgets/app_primary_button.dart'; +import 'package:gopeed/shared/widgets/app_tooltip.dart'; +import 'package:gopeed/shared/widgets/app_toast.dart'; +import 'package:gopeed/shared/widgets/gopeed_app_mark.dart'; +import 'package:gopeed/shared/widgets/responsive_menu_layout.dart'; +import 'package:gopeed/shared/widgets/virtual_tree_view.dart'; +import 'package:gopeed/shared/widgets/window/desktop_window_header.dart'; +import 'package:gopeed/util/updater.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async {}); + TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + + test('macOS reserves its native overlay header without custom chrome', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + expect(AppWindowChrome.isDesktopWindow, isTrue); + expect(AppWindowChrome.usesCustomChrome, isFalse); + expect(AppWindowChrome.reservesHeaderInset, isTrue); + expect(AppWindowChrome.clipsRoundedCorners, isFalse); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('desktop frame overlays global caption controls without insetting content', (WidgetTester tester) async { + const windowChannel = MethodChannel('window_manager'); + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + windowChannel, + (call) async => call.method == 'isMaximized' ? false : null, + ); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(windowChannel, null); + }); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const AppWindowFrame(child: SizedBox.expand(key: ValueKey('window-content'))), + ), + ); + await tester.pump(); + + expect(find.byType(DesktopWindowHeader), findsOneWidget); + expect(find.byType(DragToResizeArea), findsOneWidget); + expect( + find.descendant(of: find.byType(DesktopWindowHeader), matching: find.byType(WindowCaptionControls)), + findsOneWidget, + ); + expect(tester.getSize(find.byType(DesktopWindowHeader)).height, AppDesignTokens.windowHeaderHeight); + expect(tester.getTopLeft(find.byKey(const ValueKey('window-content'))), Offset.zero); + + debugDefaultTargetPlatformOverride = null; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(windowChannel, null); + }); + + testWidgets('tasks page renders key content', (WidgetTester tester) async { + tester.view.physicalSize = const Size(1024, 768); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(FakeTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Downloading'), findsOneWidget); + expect(find.text('Completed'), findsOneWidget); + expect(find.text('Failed'), findsOneWidget); + expect(find.text('Create Task'), findsOneWidget); + expect(find.text('No tasks in this list'), findsOneWidget); + expect(find.text('Add Task'), findsNothing); + expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget); + expect(find.text('Create a task or refresh the list.'), findsNothing); + expect(find.text('Refresh'), findsNothing); + expect(find.textContaining('ACTIVE CONNECTIONS'), findsNothing); + expect(find.ancestor(of: find.text('Create Task'), matching: find.byType(AppPrimaryButton)), findsOneWidget); + final taskActions = find.byKey(const ValueKey('tasks-top-action-buttons')); + final taskSearch = find.byKey(const ValueKey('tasks-search-field-container')); + final createButton = find.byKey(const ValueKey('tasks-create-button-container')); + expect(find.descendant(of: taskActions, matching: find.byType(shad.IconButton)), findsNWidgets(4)); + expect(tester.getSize(taskSearch).width, 240); + expect( + tester.getRect(taskActions).right, + closeTo(tester.getRect(find.byType(TasksTopBar)).right - AppDesignTokens.space32, 0.01), + ); + expect( + tester.widget(find.byKey(const ValueKey('primary-rail-active-indicator'))).color, + AppPalette.light.textPrimary, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('primary-rail-tasks-item')), + matching: find.byType(GestureDetector), + ), + ) + .behavior, + HitTestBehavior.opaque, + ); + expect(find.byType(GopeedAppMark), findsOneWidget); + expect(find.byKey(const ValueKey('primary-rail-app-mark')), findsOneWidget); + expect(tester.getSize(find.byKey(const ValueKey('primary-rail-app-mark'))), const Size.square(24)); + expect(find.descendant(of: find.byType(GopeedAppMark), matching: find.byType(CustomPaint)), findsOneWidget); + expect(find.descendant(of: find.byType(GopeedAppMark), matching: find.byType(Image)), findsNothing); + expect(find.text('M'), findsNothing); + expect(find.text('V1.0'), findsNothing); + expect( + tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width, + AppDesignTokens.filterSidebarWidth, + ); + final primaryNavigationTop = tester.getTopLeft(find.byKey(const ValueKey('primary-rail-tasks-item'))).dy; + expect( + tester.getTopLeft(find.byKey(const ValueKey('secondary-navigation-item-0'))).dy, + closeTo(primaryNavigationTop, 0.01), + ); + expect( + tester.getTopLeft(find.byKey(const ValueKey('tasks-content-start'))).dy, + closeTo(primaryNavigationTop, 0.01), + ); + expect(tester.getSize(find.byKey(const ValueKey('speed-monitor-gauge'))), const Size(60, 32)); + final gaugeRect = tester.getRect(find.byKey(const ValueKey('speed-monitor-gauge'))); + final downloadLineRect = tester.getRect(find.byKey(const ValueKey('speed-monitor-download-line'))); + final valuesRect = tester.getRect(find.byKey(const ValueKey('speed-monitor-values'))); + expect(gaugeRect.right, lessThan(downloadLineRect.left)); + expect(gaugeRect.center.dy, closeTo(valuesRect.center.dy, 0.01)); + expect(find.text('B/s'), findsNWidgets(2)); + expect( + find.descendant(of: find.byKey(const ValueKey('speed-monitor-values')), matching: find.byType(Icon)), + findsNWidgets(2), + ); + + tester.view.physicalSize = const Size(800, 768); + await tester.pumpAndSettle(); + final compactSearchWidth = tester.getSize(taskSearch).width; + expect(tester.getSize(find.byType(PrimaryRail)).width, AppDesignTokens.railWidth); + expect(compactSearchWidth, lessThan(240)); + expect(tester.getRect(createButton).left - tester.getRect(taskSearch).right, greaterThanOrEqualTo(16)); + expect( + tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width, + AppDesignTokens.filterSidebarWidth, + ); + expect(find.byKey(const ValueKey('speed-monitor-gauge')), findsOneWidget); + expect( + tester.getRect(taskActions).right, + closeTo(tester.getRect(find.byType(TasksTopBar)).right - AppDesignTokens.space32, 0.01), + ); + expect(tester.takeException(), isNull); + + final primaryRailGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await primaryRailGesture.addPointer( + location: tester.getCenter(find.byKey(const ValueKey('primary-rail-tasks-item'))), + ); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Tasks'), findsOneWidget); + await primaryRailGesture.removePointer(); + await tester.pumpAndSettle(); + + tester.view.physicalSize = const Size(720, 768); + await tester.pumpAndSettle(); + expect(find.byType(PrimaryRail), findsOneWidget); + expect(tester.getSize(find.byType(PrimaryRail)).width, AppDesignTokens.railWidth); + expect(tester.getSize(taskSearch).width, lessThan(compactSearchWidth)); + expect(tester.getRect(createButton).left - tester.getRect(taskSearch).right, greaterThanOrEqualTo(16)); + expect( + tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width, + AppDesignTokens.filterSidebarWidth, + ); + expect( + tester.getRect(taskActions).right, + closeTo(tester.getRect(find.byType(TasksTopBar)).right - AppDesignTokens.space32, 0.01), + ); + expect(tester.takeException(), isNull); + + tester.view.physicalSize = const Size(719, 768); + await tester.pumpAndSettle(); + expect(find.byType(PrimaryRail), findsNothing); + expect(find.byType(PrimaryBottomNavigation), findsOneWidget); + }); + + testWidgets('task loading failures use the empty state without retry controls', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(FailingTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No tasks in this list'), findsOneWidget); + expect(find.byKey(const ValueKey('task-empty-illustration')), findsOneWidget); + expect(find.text('Retry'), findsNothing); + expect(find.text('Unable to load tasks'), findsNothing); + }); + + testWidgets('android root navigation requires a second back action to exit', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 760)); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var systemPopCalls = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'SystemNavigator.pop') systemPopCalls++; + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(FakeTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text('Press back again to exit'), findsOneWidget); + expect(systemPopCalls, 0); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(systemPopCalls, 1); + await tester.pump(const Duration(seconds: 6)); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('task search uses a specific empty result message', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(RecordingTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.enterText(find.byType(shad.TextField), 'not-a-task'); + await tester.pump(); + + expect(find.text('No matching tasks found'), findsOneWidget); + expect(find.text('No tasks in this list'), findsNothing); + }); + + testWidgets('tasks page mobile header places actions above text tabs', (WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 760); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(FakeTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Tasks'), findsNWidgets(2)); + expect(find.text('Downloading 0'), findsOneWidget); + expect(find.text('Completed 0'), findsOneWidget); + expect(find.text('Failed 0'), findsOneWidget); + }); + + testWidgets('extensions grid adds columns from a minimum card width and keeps desktop toolbar aligned', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + int gridColumns() { + final grid = tester.widget(find.byType(SliverGrid)); + return (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount).crossAxisCount; + } + + expect(gridColumns(), 3); + final searchRect = tester.getRect(find.byKey(const ValueKey('extension-search-input'))); + final sortRect = tester.getRect(find.byKey(const ValueKey('extension-sort-control'))); + final refreshRect = tester.getRect(find.byKey(const ValueKey('refresh-extensions-button'))); + final developRect = tester.getRect(find.byKey(const ValueKey('develop-extension-button'))); + final installRect = tester.getRect(find.byKey(const ValueKey('install-extension-button'))); + final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0'))); + final rightmostCardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))); + final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark'))); + expect(searchRect.width, 240); + expect(searchRect.width, lessThan(cardRect.width)); + expect(sortRect.left - searchRect.right, greaterThanOrEqualTo(10)); + expect(sortRect.center.dy, closeTo(searchRect.center.dy, 0.01)); + expect(sortRect.height, closeTo(refreshRect.height, 0.01)); + expect(searchRect.center.dy, closeTo(appMarkRect.center.dy, 0.01)); + expect(installRect.center.dy, closeTo(searchRect.center.dy, 0.01)); + expect(developRect.right, lessThan(installRect.left)); + expect(installRect.left, greaterThan(sortRect.right)); + expect(installRect.right, closeTo(rightmostCardRect.right, 0.01)); + + tester.view.physicalSize = const Size(1100, 600); + await tester.pumpAndSettle(); + expect( + tester.getRect(find.byKey(const ValueKey('install-extension-button'))).right, + closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01), + ); + + tester.view.physicalSize = const Size(1028, 608); + await tester.pumpAndSettle(); + expect(gridColumns(), 3); + expect(tester.getSize(find.byKey(const ValueKey('extension-search-field-container'))).width, 240); + expect( + tester.getRect(find.byKey(const ValueKey('install-extension-button'))).right, + closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01), + ); + + tester.view.physicalSize = const Size(720, 608); + await tester.pumpAndSettle(); + expect(find.byType(PrimaryRail), findsOneWidget); + final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container'))); + expect(compactSearchRect.width, lessThan(240)); + expect( + tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).center.dy, + closeTo(compactSearchRect.center.dy, 0.01), + ); + expect(tester.takeException(), isNull); + + tester.view.physicalSize = const Size(1400, 900); + await tester.pumpAndSettle(); + expect(gridColumns(), 4); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + final controller = FakeExtensionsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('extension-card-extension-1'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('extension-details-drawer')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-content')), findsOneWidget); + expect(find.text('Extension 1 README'), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-close')), findsNothing); + expect( + find.descendant(of: find.byKey(const ValueKey('extension-details-drawer')), matching: find.byIcon(Icons.close)), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('app-detail-drawer-close-button')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-hero')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-metadata')), findsNothing); + final stats = find.byKey(const ValueKey('extension-details-stats')); + expect(stats, findsOneWidget); + expect(find.descendant(of: stats, matching: find.byIcon(Icons.star_rounded)), findsOneWidget); + expect(find.descendant(of: stats, matching: find.byIcon(Icons.download_outlined)), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-readme')), findsOneWidget); + expect(find.text('#download'), findsNothing); + for (final key in ['extension-details-homepage', 'extension-details-github']) { + expect(tester.widget(find.byKey(ValueKey(key))), isA()); + } + final drawerRect = tester.getRect(find.byKey(const ValueKey('extension-details-drawer'))); + final filterBarRect = tester.getRect(find.byKey(const ValueKey('extensions-filter-bar'))); + final listRect = tester.getRect(find.byKey(const ValueKey('extensions-list-scroll-view'))); + expect(drawerRect.top, filterBarRect.top); + expect(drawerRect.top, lessThan(listRect.top)); + expect(drawerRect.bottom, listRect.bottom); + expect(drawerRect.top, greaterThan(AppDesignTokens.windowHeaderHeight)); + + await tester.tap(find.byKey(const ValueKey('extension-details-install'))); + await tester.pump(const Duration(milliseconds: 300)); + expect(controller.installCalls, 1); + expect(find.byKey(const ValueKey('app-toast-content')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('app-detail-drawer-close-button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('extension-details-content')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension details open as a standalone route on mobile', (WidgetTester tester) async { + await _setTestSize(tester, const Size(320, 700)); + final router = GoRouter( + initialLocation: '/extensions', + routes: [ + GoRoute( + path: '/extensions', + builder: (context, state) => const ExtensionsPage(), + routes: [ + GoRoute( + path: ':id', + builder: (context, state) => ExtensionDetailsPage( + extensionId: state.pathParameters['id'] ?? '', + initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, + ), + ), + ], + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); + + final card = find.byKey(const ValueKey('open-extension-details-extension-0')); + expect(MediaQuery.sizeOf(tester.element(card)).width, 320); + tester.widget(card).onTap!(); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('extension-details-content')), findsOneWidget); + expect(find.text('Extension 0 README'), findsOneWidget); + expect(find.byType(PrimaryBottomNavigation), findsNothing); + expect(find.byKey(const ValueKey('extensions-list-scroll-view')), findsNothing); + expect(find.byKey(const ValueKey('app-detail-back-button')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-close')), findsNothing); + expect( + GoRouterState.of(tester.element(find.byKey(const ValueKey('extension-details-content')))).uri.path, + '/extensions/extension-0', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('installed extension details use a disabled installed button without a hero status', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('extension-card-extension-0'))); + await tester.pumpAndSettle(); + + final hero = find.byKey(const ValueKey('extension-details-hero')); + final installedButton = find.byKey(const ValueKey('extension-details-installed')); + final primaryButton = find.descendant(of: installedButton, matching: find.byType(shad.PrimaryButton)); + expect(find.descendant(of: hero, matching: find.text('Installed')), findsNothing); + expect(installedButton, findsOneWidget); + expect(find.descendant(of: installedButton, matching: find.text('Installed')), findsOneWidget); + expect(tester.widget(primaryButton).onPressed, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('installed extension details prefer the local README', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + late Directory extensionDirectory; + await tester.runAsync(() async { + extensionDirectory = await Directory.systemTemp.createTemp('gopeed-extension-readme-'); + await File( + '${extensionDirectory.path}${Platform.pathSeparator}README.md', + ).writeAsString('# Local extension README\n\nLoaded from the installed extension.'); + }); + addTearDown(() => extensionDirectory.delete(recursive: true)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + extensionsControllerProvider.overrideWith(() => DevReadmeExtensionsController(extensionDirectory.path)), + ], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('extension-card-extension-0'))); + await tester.pump(const Duration(milliseconds: 300)); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + + expect(find.text('Local extension README'), findsOneWidget); + expect(find.text('Extension 0 README'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension initial loading uses card skeletons without a progress spinner', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(LoadingExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pump(); + + expect(find.byKey(const ValueKey('extensions-initial-skeleton')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-skeleton-card-0')), findsOneWidget); + expect(find.byType(shad.CircularProgressIndicator), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension pagination keeps the bottom progress spinner', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(LoadingMoreExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byKey(const ValueKey('extensions-initial-skeleton')), findsNothing); + expect(find.byKey(const ValueKey('extension-card-extension-0')), findsOneWidget); + expect(find.byType(shad.CircularProgressIndicator), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('extensions mobile toolbar gives search its own row and keeps actions split below', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(390, 760)); + tester.view.padding = const FakeViewPadding(top: 32, bottom: 24); + addTearDown(tester.view.resetPadding); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + final searchRect = tester.getRect(find.byKey(const ValueKey('extension-search-input'))); + final sortRect = tester.getRect(find.byKey(const ValueKey('extension-sort-control'))); + final developRect = tester.getRect(find.byKey(const ValueKey('develop-extension-button'))); + final installRect = tester.getRect(find.byKey(const ValueKey('install-extension-button'))); + final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0'))); + + expect(searchRect.top, greaterThanOrEqualTo(32)); + expect(searchRect.width, closeTo(358, 0.01)); + expect(searchRect.width, closeTo(cardRect.width, 0.01)); + expect(sortRect.top, greaterThan(searchRect.bottom)); + expect(sortRect.left, closeTo(searchRect.left, 0.01)); + expect(installRect.right, closeTo(searchRect.right, 0.01)); + expect(installRect.center.dy, closeTo(sortRect.center.dy, 0.01)); + expect(developRect.right, lessThan(installRect.left)); + + final list = find.byKey(const ValueKey('extensions-list-scroll-view')); + final scrollable = find.descendant(of: list, matching: find.byType(Scrollable)); + await tester.drag(list, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('extension-search-input'))).top, closeTo(searchRect.top, 0.01)); + expect(tester.state(scrollable).position.pixels, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + + testWidgets('removing an extension requires explicit confirmation', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + late FakeExtensionsController controller; + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller = FakeExtensionsController())], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + final removeButton = find.byKey(const ValueKey('remove-extension-extension-0')); + await tester.tap(removeButton); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('remove-extension-dialog')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('cancel-remove-extension-button'))); + await tester.pumpAndSettle(); + expect(controller.removeCalls, 0); + expect(find.byKey(const ValueKey('remove-extension-dialog')), findsNothing); + + await tester.tap(removeButton); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('confirm-remove-extension-button'))); + await tester.pumpAndSettle(); + expect(controller.removeCalls, 1); + expect(find.byKey(const ValueKey('remove-extension-dialog')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension market automatically loads the next page near the list bottom', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 760)); + late PaginatedExtensionsController controller; + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller = PaginatedExtensionsController())], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Load More'), findsNothing); + expect(controller.loadMoreCalls, 0); + + await tester.fling(find.byKey(const ValueKey('extensions-list-scroll-view')), const Offset(0, -1400), 3000); + await tester.pumpAndSettle(); + + expect(controller.loadMoreCalls, 1); + expect(find.text('Load More'), findsNothing); + expect(find.byKey(const ValueKey('extensions-no-more-indicator')), findsOneWidget); + expect(find.text('No more extensions'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension install actions show progress instead of a disabled icon while busy', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(BusyInstallExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final manualInstall = find.byKey(const ValueKey('install-extension-button')); + final storeInstall = find.byKey(const ValueKey('install-store-extension-extension-1')); + expect(find.descendant(of: manualInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + expect(find.descendant(of: storeInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + expect(find.descendant(of: storeInstall, matching: find.byIcon(Icons.download)), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('extension install popover unlocks local loading and installed cards use switches', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(shad.Switch), findsOneWidget); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + final installButton = find.byKey(const ValueKey('install-extension-button')); + expect(find.descendant(of: installButton, matching: find.byType(shad.IconButton)), findsOneWidget); + + for (var index = 0; index < 5; index++) { + await tester.tap(installButton); + await tester.pump(const Duration(milliseconds: 100)); + } + + expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-install-url-input')), findsOneWidget); + final localButton = find.byKey(const ValueKey('load-local-extension-button')); + expect(localButton, findsOneWidget); + expect(find.descendant(of: localButton, matching: find.byType(shad.IconButton)), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(tester.takeException(), isNull); + }); + + testWidgets('responsive menu uses sidebar on desktop and two-level navigation on mobile', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget(const _ResponsiveMenuHarness()); + await tester.pumpAndSettle(); + + expect(find.text('SETTINGS'), findsOneWidget); + expect(find.text('General content'), findsOneWidget); + + await _setTestSize(tester, const Size(390, 760)); + await tester.pumpWidget(const _ResponsiveMenuHarness()); + await tester.pumpAndSettle(); + + expect(find.text('SETTINGS'), findsOneWidget); + expect(find.text('General content'), findsNothing); + + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + expect(find.text('Advanced content'), findsOneWidget); + expect(find.text('SETTINGS'), findsNothing); + }); + + testWidgets('responsive menu keeps the full desktop sidebar from 720 pixels', (WidgetTester tester) async { + await _setTestSize(tester, const Size(800, 760)); + await tester.pumpWidget(const _ResponsiveMenuHarness()); + await tester.pumpAndSettle(); + + final pane = find.byKey(const ValueKey('secondary-navigation-pane')); + expect(tester.getSize(pane).width, AppDesignTokens.filterSidebarWidth); + expect(find.text('SETTINGS'), findsOneWidget); + expect(find.text('General'), findsOneWidget); + expect(find.text('General content'), findsOneWidget); + }); + + testWidgets('responsive mobile menu reserves the safe area and uses comfortable entry sizes', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(390, 760)); + tester.view.padding = const FakeViewPadding(top: 32, bottom: 24); + addTearDown(tester.view.resetPadding); + await tester.pumpWidget(const _ResponsiveMenuHarness()); + await tester.pumpAndSettle(); + + final pane = find.byKey(const ValueKey('secondary-navigation-pane')); + final firstEntry = find.byKey(const ValueKey('secondary-navigation-item-0')); + expect(tester.getTopLeft(pane).dy, 32); + expect(tester.getSize(firstEntry).height, greaterThanOrEqualTo(52)); + + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + final mobileHeader = find.byKey(const ValueKey('mobile-content-header')); + expect(tester.getTopLeft(mobileHeader).dy, 32); + expect(tester.getSize(mobileHeader).height, 56); + }); + + testWidgets('settings use three top-level entries and ordered groups', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Basic'), findsWidgets); + expect(find.text('Downloads'), findsOneWidget); + expect(find.text('Advanced'), findsOneWidget); + expect(find.text('网络设置'), findsNothing); + expect( + tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width, + AppDesignTokens.filterSidebarWidth, + ); + final primaryNavigationTop = tester.getTopLeft(find.byKey(const ValueKey('primary-rail-tasks-item'))).dy; + expect( + tester.getTopLeft(find.byKey(const ValueKey('secondary-navigation-item-0'))).dy, + closeTo(primaryNavigationTop, 0.01), + ); + expect( + tester.getTopLeft(find.byKey(const ValueKey('settings-body-content'))).dy, + closeTo(primaryNavigationTop, 0.01), + ); + expect(find.byKey(const ValueKey('theme-mode-system')), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + expect(find.text('Dark'), findsOneWidget); + expect(find.text('Accent Color'), findsOneWidget); + expect(find.text('强调色'), findsNothing); + expect(find.byKey(const ValueKey('theme-color-green')), findsOneWidget); + expect(find.byKey(const ValueKey('theme-color-purple')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-language-select')), findsOneWidget); + expect(find.text('Browser Extension'), findsOneWidget); + expect(find.byKey(const ValueKey('browser-extension-chrome')), findsOneWidget); + expect(find.byKey(const ValueKey('browser-extension-edge')), findsOneWidget); + expect(find.byKey(const ValueKey('browser-extension-firefox')), findsOneWidget); + expect(find.text('Version'), findsOneWidget); + expect(find.text('Current version -'), findsOneWidget); + expect(find.byKey(const ValueKey('check-app-update-button')), findsOneWidget); + expect(find.text('Check for Updates'), findsOneWidget); + expect(find.text('Usage Analytics'), findsOneWidget); + expect(find.text('Contributors'), findsOneWidget); + expect(find.byKey(const ValueKey('gopeed-homepage')), findsOneWidget); + expect(find.byKey(const ValueKey('gopeed-github')), findsOneWidget); + expect(find.byKey(const ValueKey('gopeed-contributors')), findsOneWidget); + final aboutOrder = [ + 'Homepage', + 'GitHub', + 'Contributors', + 'Usage Analytics', + 'Notify for updates', + 'Version', + ].map((label) => tester.getTopLeft(find.text(label)).dy).toList(); + expect(aboutOrder, orderedEquals([...aboutOrder]..sort())); + for (final (key, label) in [ + ('gopeed-homepage', 'gopeed.com'), + ('gopeed-github', 'github.com/GopeedLab/gopeed'), + ('gopeed-contributors', 'View contributors'), + ]) { + final link = find.byKey(ValueKey(key)); + expect(find.descendant(of: link, matching: find.byType(shad.SecondaryButton)), findsNothing); + expect(find.descendant(of: link, matching: find.byIcon(Icons.open_in_new)), findsOneWidget); + expect( + tester.widget(find.descendant(of: link, matching: find.text(label))).style?.decoration, + TextDecoration.underline, + ); + } + final swatchCenters = AppThemeColor.values + .map((color) => tester.getCenter(find.byKey(ValueKey('theme-color-${color.key}'))).dy) + .toList(); + expect(swatchCenters.reduce((a, b) => a < b ? a : b), closeTo(swatchCenters.first, 0.01)); + expect(swatchCenters.reduce((a, b) => a > b ? a : b), closeTo(swatchCenters.first, 0.01)); + + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('download-categories-editor')), findsOneWidget); + expect(find.text('Archives'), findsNothing); + final httpSettings = find.byKey(const ValueKey('settings-http-block')); + expect(find.descendant(of: httpSettings, matching: find.text('Extract Archives Automatically')), findsOneWidget); + expect(find.descendant(of: httpSettings, matching: find.text('Delete Archives After Extraction')), findsOneWidget); + expect(find.text('Delete .torrent file after BT task creation'), findsNothing); + expect(find.text('HTTP'), findsOneWidget); + expect(find.text('BitTorrent'), findsOneWidget); + expect(find.text('ED2K'), findsOneWidget); + + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(find.text('Network'), findsOneWidget); + expect(find.text('API'), findsOneWidget); + expect(find.text('Developer'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('settings keep the desktop shell at the 720 pixel boundary', (WidgetTester tester) async { + await _setTestSize(tester, const Size(720, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PrimaryRail), findsOneWidget); + expect(find.byType(PrimaryBottomNavigation), findsNothing); + expect( + tester.getSize(find.byKey(const ValueKey('secondary-navigation-pane'))).width, + AppDesignTokens.filterSidebarWidth, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('dependent settings follow the legacy visibility rules', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + Finder settingSwitch(String label) => find.descendant( + of: find.ancestor(of: find.text(label), matching: find.byType(SettingsItem)), + matching: find.byType(shad.Switch), + ); + + await tester.tap(settingSwitch('Extract Archives Automatically')); + await tester.pumpAndSettle(); + expect(find.text('Delete Archives After Extraction'), findsNothing); + + await tester.tap(settingSwitch('Auto create BT tasks from .torrent files')); + await tester.pumpAndSettle(); + expect(find.text('Delete .torrent file after BT task creation'), findsOneWidget); + + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(find.text('Protocol'), findsOneWidget); + expect(find.text('Username'), findsNothing); + await tester.tap(find.text('Custom Proxy')); + await tester.pumpAndSettle(); + expect(find.text('Protocol'), findsNWidgets(2)); + expect(find.text('Username'), findsOneWidget); + expect(find.text('Password'), findsOneWidget); + }); + + testWidgets('numeric settings use compact bounded spinner inputs', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final settingsController = FakeSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final maxRunning = find.byKey(const ValueKey('max-running-input')); + expect(maxRunning, findsOneWidget); + expect(find.text('Maximum number of tasks that may download at the same time'), findsOneWidget); + expect(tester.getSize(maxRunning).width, AppDesignTokens.settingsNumberControlWidth); + expect(tester.widget(maxRunning).features.single, isA()); + + await tester.enterText(maxRunning, '999'); + await tester.pump(const Duration(milliseconds: 700)); + + expect(tester.widget(maxRunning).controller!.text, '256'); + expect(settingsController.state.requireValue.config.maxRunning, 256); + + final seedRatio = find.byKey(const ValueKey('bt-seed-ratio-input')); + await tester.ensureVisible(seedRatio); + await tester.pumpAndSettle(); + await tester.enterText(seedRatio, '1.1'); + final seedRatioStepperButtons = find.descendant(of: seedRatio, matching: find.byType(shad.IconButton)); + expect(seedRatioStepperButtons, findsNWidgets(2)); + await tester.tap(seedRatioStepperButtons.first); + await tester.pump(); + expect(tester.widget(seedRatio).controller!.text, '1.2'); + expect(tester.takeException(), isNull); + }); + + testWidgets('download directory keeps long text clear of its aligned picker action', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(CategorySettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final directoryInput = find.byKey(const ValueKey('download-directory-input')); + final directoryPicker = find.byKey(const ValueKey('download-directory-picker')); + final standardInput = find.byKey(const ValueKey('http-user-agent-input')); + expect(find.text('Automatically save last task directory'), findsNothing); + expect(tester.getSize(directoryInput).height, tester.getSize(standardInput).height); + final directoryField = tester.widget(directoryInput); + final standardField = tester.widget(standardInput); + expect(directoryField.filled, standardField.filled); + expect(directoryField.border, standardField.border); + expect(directoryField.borderRadius, standardField.borderRadius); + expect(directoryField.padding, standardField.padding); + const longDirectory = + '/storage/emulated/0/Download/a-very-long-directory-name-that-must-not-run-under-the-picker-icon'; + await tester.enterText(directoryInput, longDirectory); + await tester.pump(); + + final editableText = find.descendant(of: directoryInput, matching: find.byType(EditableText)); + expect(tester.getRect(editableText).right, lessThan(tester.getRect(directoryPicker).left)); + expect(find.descendant(of: directoryInput, matching: directoryPicker), findsOneWidget); + expect(tester.getRect(directoryPicker).right, closeTo(tester.getRect(directoryInput).right, 0.01)); + expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); + final pathTooltip = find.ancestor(of: directoryInput, matching: find.byType(AppTooltip)); + expect(pathTooltip, findsOneWidget); + expect(tester.widget(pathTooltip).message, longDirectory); + + final categoryActions = find.descendant( + of: find.byKey(const ValueKey('download-categories-editor')), + matching: find.byType(shad.GhostButton), + ); + expect(categoryActions, findsWidgets); + expect(tester.getSize(directoryPicker).height, tester.getSize(directoryInput).height); + expect(tester.getTopRight(directoryPicker).dx, closeTo(tester.getTopRight(categoryActions.last).dx, 0.01)); + expect(tester.takeException(), isNull); + }); + + testWidgets('android download directory picker only switches to verified raw paths', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 760)); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + addTearDown(() => DownloadDirectoryPicker.debugPlatformOverride = null); + final calls = []; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async { + calls.add('getLocations'); + return { + 'application': '/storage/emulated/0/Android/data/com.gopeed.gopeed/files', + 'downloads': '/storage/emulated/0/Download/Gopeed', + }; + }; + DownloadDirectoryPicker.debugDownloadsPreparer = (path) async { + calls.add('prepareDownloads'); + return path; + }; + addTearDown(() { + DownloadDirectoryPicker.debugAndroidLocationsLoader = null; + DownloadDirectoryPicker.debugDownloadsPreparer = null; + }); + final controller = TextEditingController(text: '/storage/emulated/0/Android/data/com.gopeed.gopeed/files'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(20), + child: AppPathPickerField.downloadDirectory( + controller: controller, + fieldKey: const ValueKey('android-directory-input'), + pickerKey: const ValueKey('android-directory-picker'), + ), + ), + ), + ); + + expect(tester.widget(find.byKey(const ValueKey('android-directory-input'))).readOnly, isTrue); + await tester.tap(find.byKey(const ValueKey('android-directory-picker'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('android-download-directory-dialog')), findsOneWidget); + expect(find.text('App storage'), findsOneWidget); + expect(find.text('Download/Gopeed'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); + await tester.pumpAndSettle(); + expect(controller.text, '/storage/emulated/0/Download/Gopeed'); + expect(calls, ['getLocations', 'prepareDownloads']); + expect(find.byKey(const ValueKey('android-download-directory-dialog')), findsNothing); + expect(tester.takeException(), isNull); + DownloadDirectoryPicker.debugPlatformOverride = null; + }); + + testWidgets('ios download directory is fixed and has no picker action', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 760)); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.iOS; + addTearDown(() => DownloadDirectoryPicker.debugPlatformOverride = null); + final controller = TextEditingController(text: '/app/Documents'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(20), + child: AppPathPickerField.downloadDirectory( + controller: controller, + fieldKey: const ValueKey('ios-directory-input'), + pickerKey: const ValueKey('ios-directory-picker'), + ), + ), + ), + ); + + expect(tester.widget(find.byKey(const ValueKey('ios-directory-input'))).readOnly, isTrue); + expect(find.byKey(const ValueKey('ios-directory-picker')), findsNothing); + expect(tester.takeException(), isNull); + DownloadDirectoryPicker.debugPlatformOverride = null; + }); + + testWidgets('web download directory supports manual input without a picker action', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 300)); + DownloadDirectoryPicker.debugWebOverride = true; + addTearDown(() => DownloadDirectoryPicker.debugWebOverride = null); + final controller = TextEditingController(text: '/downloads'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(20), + child: AppPathPickerField.downloadDirectory( + controller: controller, + fieldKey: const ValueKey('web-directory-input'), + pickerKey: const ValueKey('web-directory-picker'), + ), + ), + ), + ); + + expect(tester.widget(find.byKey(const ValueKey('web-directory-input'))).readOnly, isFalse); + expect(find.byKey(const ValueKey('web-directory-picker')), findsNothing); + await tester.enterText(find.byKey(const ValueKey('web-directory-input')), '/downloads/manual'); + expect(controller.text, '/downloads/manual'); + }); + + testWidgets('ED2K list settings use multiline text and persist comma-separated values', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final settingsController = Ed2kSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final servers = find.byKey(const ValueKey('ed2k-server-address-input')); + final serverMet = find.byKey(const ValueKey('ed2k-server-met-input')); + final nodesDat = find.byKey(const ValueKey('ed2k-nodes-dat-input')); + expect(tester.widget(servers).controller!.text, 'server-a:4661\nserver-b:4661'); + expect(tester.widget(servers).maxLines, 5); + expect(tester.widget(serverMet).maxLines, 4); + expect(tester.widget(nodesDat).maxLines, 4); + + await tester.ensureVisible(servers); + await tester.pumpAndSettle(); + await tester.enterText(servers, 'one.example:4661\ntwo.example:4661'); + await tester.pump(const Duration(milliseconds: 700)); + expect( + settingsController.state.requireValue.config.protocolConfig.ed2k.serverAddr, + 'one.example:4661,two.example:4661', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('proxy and API TCP addresses use separate host and port inputs', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final settingsController = ProxySettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + String inputText(String key) => tester.widget(find.byKey(ValueKey(key))).controller!.text; + + expect(inputText('proxy-host-input'), 'proxy.example.com'); + expect(inputText('proxy-port-input'), '1080'); + expect(inputText('api-host-input'), '127.0.0.1'); + expect(inputText('api-port-input'), '9999'); + expect( + tester.widget(find.byKey(const ValueKey('proxy-port-input'))).features.single, + isA(), + ); + + await tester.enterText(find.byKey(const ValueKey('proxy-port-input')), '3128'); + await tester.pump(const Duration(milliseconds: 700)); + expect(settingsController.state.requireValue.config.proxy.host, 'proxy.example.com:3128'); + expect(tester.takeException(), isNull); + }); + + testWidgets('API settings keep a validated draft until the save button is pressed', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final runtimeController = RecordingStartConfigRuntimeController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => runtimeController), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(dark: true, wrapComponentThemes: true), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + final host = find.byKey(const ValueKey('api-host-input')); + final port = find.byKey(const ValueKey('api-port-input')); + final saveButton = find.byKey(const ValueKey('save-api-config-button')); + expect(tester.widget(saveButton).variant, AppLoadingButtonVariant.secondary); + final saveButtonClickable = find.descendant(of: saveButton, matching: find.byType(shad.Clickable)); + final disabledDecoration = + tester.widget(saveButtonClickable).decoration!.resolve({WidgetState.disabled}) as BoxDecoration; + expect(disabledDecoration.color, AppPalette.dark.surfaceSoft); + expect(disabledDecoration.color, isNot(AppPalette.dark.brand)); + expect(tester.widget(host).controller!.text, '192.168.1.20'); + expect(tester.widget(port).controller!.text, '4321'); + expect(tester.widget(saveButton).onPressed, isNull); + + await tester.enterText(host, ''); + await tester.enterText(port, '0'); + await tester.pump(const Duration(seconds: 1)); + expect(runtimeController.savedStartConfig, isNull); + expect(find.text('Unsaved API changes'), findsNothing); + expect(tester.widget(saveButton).onPressed, isNotNull); + + await tester.ensureVisible(saveButton); + await tester.pumpAndSettle(); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + expect(runtimeController.savedStartConfig, isNull); + + await tester.enterText(host, '127.0.0.1'); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + expect(runtimeController.savedStartConfig?.network, 'tcp'); + expect(runtimeController.savedStartConfig?.address, '127.0.0.1:0'); + expect(tester.widget(saveButton).onPressed, isNull); + expect(find.text('Restart required'), findsNothing); + expect(find.text('Auto saved'), findsNothing); + + tester.view.physicalSize = const Size(390, 900); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + final protocolItem = find.ancestor(of: find.text('Protocol'), matching: find.byType(SettingsItem)); + final tcpChoice = find.byKey(const ValueKey('settings-choice-tcp')); + expect(tester.getRect(tcpChoice).left, closeTo(tester.getRect(protocolItem).left, 0.01)); + + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('shared toast appears at top center with content-driven width', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 700)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) => Center( + child: shad.PrimaryButton( + onPressed: () => showAppToast(context, '保存成功', type: AppToastType.success), + child: const Text('Show toast'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Show toast')); + await tester.pump(const Duration(milliseconds: 600)); + + final toast = find.byKey(const ValueKey('app-toast-content')); + expect(toast, findsOneWidget); + expect(tester.getSize(toast).width, lessThan(320)); + final toastRect = tester.getRect(toast); + expect(toastRect.center.dx, closeTo(512, 0.01)); + expect(toastRect.top, lessThan(80)); + final decoration = tester.widget(toast).decoration! as BoxDecoration; + expect((decoration.border! as Border).top.color, AppPalette.light.border); + + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('tracker subscriptions support selecting and clearing all entries', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + tester.platformDispatcher.textScaleFactorTestValue = 1.2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final settingsController = FakeSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final selectAll = find.byKey(const ValueKey('tracker-select-all')); + expect(selectAll, findsOneWidget); + expect(find.descendant(of: selectAll, matching: find.text('Select All')), findsOneWidget); + + await tester.ensureVisible(selectAll); + await tester.pumpAndSettle(); + final horizontalScrollbar = tester.widget(find.byKey(const ValueKey('tracker-horizontal-scrollbar'))); + expect(horizontalScrollbar.thumbVisibility, isTrue); + expect(horizontalScrollbar.controller?.hasClients, isTrue); + expect(horizontalScrollbar.controller!.position.maxScrollExtent, greaterThan(0)); + final verticalScrollbar = tester.widget(find.byKey(const ValueKey('tracker-vertical-scrollbar'))); + expect(verticalScrollbar.controller?.hasClients, isTrue); + expect(verticalScrollbar.controller!.position.maxScrollExtent, greaterThan(0)); + final scrollbarRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scrollbar'))); + final scrollViewRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scroll-view'))); + expect(scrollbarRect.bottom - scrollViewRect.bottom, AppDesignTokens.space12); + expect(find.text(allTrackerSubscribeUrls.first), findsOneWidget); + horizontalScrollbar.controller!.jumpTo(horizontalScrollbar.controller!.position.maxScrollExtent); + await tester.pump(); + expect(horizontalScrollbar.controller!.offset, greaterThan(0)); + + await tester.tap(selectAll); + await tester.pumpAndSettle(); + expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, allTrackerSubscribeUrls); + + await tester.tap(selectAll); + await tester.pumpAndSettle(); + expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('tracker update button keeps its loading indicator circular', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final runtimeController = PendingTrackerRuntimeController(); + final settingsController = StaticSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => runtimeController), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final updateButton = find.byKey(const ValueKey('tracker-update-button')); + await tester.ensureVisible(updateButton); + await tester.pumpAndSettle(); + final sectionScrollView = find.byKey(const ValueKey('settings-section-scroll-view')); + final sectionScrollable = find.descendant(of: sectionScrollView, matching: find.byType(Scrollable)).first; + final offsetBeforeUpdate = tester.state(sectionScrollable).position.pixels; + expect(offsetBeforeUpdate, greaterThan(0)); + await tester.tap(updateButton); + await tester.pump(); + + final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)); + expect(progress, findsOneWidget); + expect(tester.getSize(progress), const Size.square(14)); + expect(find.descendant(of: updateButton, matching: find.text('Update Now')), findsOneWidget); + + runtimeController.completeTrackerUpdate(); + await tester.pumpAndSettle(); + expect(settingsController.reloadCalls, 1); + expect(settingsController.lastReloadShowLoading, isFalse); + expect(tester.state(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01)); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final runtimeController = FailingTrackerRuntimeController(); + final settingsController = StaticSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => runtimeController), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final updateButton = find.byKey(const ValueKey('tracker-update-button')); + await tester.ensureVisible(updateButton); + await tester.pumpAndSettle(); + final sectionScrollView = find.byKey(const ValueKey('settings-section-scroll-view')); + final sectionScrollable = find.descendant(of: sectionScrollView, matching: find.byType(Scrollable)).first; + final offsetBeforeUpdate = tester.state(sectionScrollable).position.pixels; + + await tester.tap(updateButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(settingsController.reloadCalls, 0); + expect(tester.state(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01)); + expect(find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)), findsNothing); + expect(find.byKey(const ValueKey('app-toast-content')), findsOneWidget); + expect(find.textContaining('tracker update failed'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + }); + + testWidgets('settings list add buttons align responsively', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + for (final key in const [ValueKey('add-webhook-button'), ValueKey('add-script-button')]) { + final button = find.byKey(key); + expect(button, findsOneWidget); + final editor = find.ancestor(of: button, matching: find.byType(SettingsListEditor)); + expect(tester.getRect(button).right, closeTo(tester.getRect(editor).right, 0.01)); + } + + tester.view.physicalSize = const Size(390, 900); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + for (final key in const [ValueKey('add-webhook-button'), ValueKey('add-script-button')]) { + final button = find.byKey(key); + final editor = find.ancestor(of: button, matching: find.byType(SettingsListEditor)); + expect(tester.getRect(button).left, closeTo(tester.getRect(editor).left, 0.01)); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('download categories use structured add and list controls', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(CategorySettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + expect(find.text('资料'), findsOneWidget); + expect(find.text('/tmp/data'), findsOneWidget); + final categoryEditor = find.byKey(const ValueKey('download-categories-editor')); + final addCategoryButton = find.byKey(const ValueKey('add-download-category')); + expect(addCategoryButton, findsOneWidget); + expect(tester.getRect(addCategoryButton).right, closeTo(tester.getRect(categoryEditor).right, 0.01)); + expect(tester.getRect(addCategoryButton).bottom, closeTo(tester.getRect(categoryEditor).bottom, 0.01)); + + await tester.tap(addCategoryButton); + await tester.pumpAndSettle(); + expect(find.byType(DownloadCategoriesControl), findsOneWidget); + await tester.enterText(find.byKey(const ValueKey('download-category-name')), '归档'); + await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/archive'); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + expect(find.text('归档'), findsOneWidget); + expect(find.text('/tmp/archive'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('edit-download-category-资料'))); + await tester.pumpAndSettle(); + expect(find.text('Edit Category'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Edit Category'), findsNothing); + expect(find.text('资料'), findsOneWidget); + + tester.view.physicalSize = const Size(390, 900); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + expect(tester.getRect(addCategoryButton).left, closeTo(tester.getRect(categoryEditor).left, 0.01)); + expect(tester.takeException(), isNull); + }); + + testWidgets('settings content stretches responsively and stays centered', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1800, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + + Rect contentRect() => tester.getRect(find.byKey(const ValueKey('settings-body-content'))); + Rect sidebarRect() => tester.getRect(find.byKey(const ValueKey('secondary-navigation-pane'))); + + final wideContent = contentRect(); + final wideSidebar = sidebarRect(); + expect(wideContent.width, AppDesignTokens.settingsContentMaxWidth); + expect(wideContent.left - wideSidebar.right, closeTo(1800 - wideContent.right, 0.01)); + expect(wideContent.left - wideSidebar.right, greaterThan(AppDesignTokens.space24)); + + tester.view.physicalSize = const Size(1280, 900); + await tester.pumpAndSettle(); + + final standardDesktopContent = contentRect(); + final standardDesktopSidebar = sidebarRect(); + expect(standardDesktopContent.width, AppDesignTokens.settingsContentMaxWidth); + expect( + standardDesktopContent.left - standardDesktopSidebar.right, + closeTo(1280 - standardDesktopContent.right, 0.01), + ); + expect(standardDesktopContent.left - standardDesktopSidebar.right, greaterThan(AppDesignTokens.space24)); + + tester.view.physicalSize = const Size(910, 900); + await tester.pumpAndSettle(); + + final compactContent = contentRect(); + final compactSidebar = sidebarRect(); + expect(compactContent.left - compactSidebar.right, AppDesignTokens.space24); + expect(910 - compactContent.right, AppDesignTokens.space24); + expect(tester.takeException(), isNull); + }); + + testWidgets('settings items keep controls wider while labels grow responsively', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1000, 700)); + const subtitle = '这是一段足够长的设置说明,用于验证窗口变宽以后左侧说明区域会随可用空间一起增长'; + + Future<({Size label, Size control})> layoutAt(double width) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Center( + child: SizedBox( + width: width, + child: const SettingsItem( + title: '设置项', + subtitle: subtitle, + child: SizedBox(key: ValueKey('responsive-setting-control'), width: 220, height: 32), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return ( + label: tester.getSize(find.text(subtitle)), + control: tester.getSize(find.byKey(const ValueKey('responsive-setting-control'))), + ); + } + + final compact = await layoutAt(600); + final wide = await layoutAt(800); + + expect(wide.label.width, greaterThan(compact.label.width)); + expect(wide.label.height, lessThan(compact.label.height)); + expect(compact.control.width, 220); + expect(wide.control.width, 220); + }); + + testWidgets('available app update uses a theme-colored icon and text button', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(AvailableUpdatePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Current version -'), findsOneWidget); + expect(find.textContaining('9.9.9'), findsNothing); + expect(find.byKey(const ValueKey('app-update-available-indicator')), findsNothing); + expect(find.text('Update'), findsOneWidget); + final button = find.byKey(const ValueKey('check-app-update-button')); + expect(find.descendant(of: button, matching: find.byIcon(Icons.system_update_alt_outlined)), findsOneWidget); + expect(find.ancestor(of: find.text('Update'), matching: find.byType(shad.SecondaryButton)), findsOneWidget); + }); + + testWidgets('checking for updates replaces only the icon with a neutral loader', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(CheckingUpdatePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(wrapComponentThemes: true), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final button = find.byKey(const ValueKey('check-app-update-button')); + final progress = find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)); + expect(progress, findsOneWidget); + expect(tester.widget(progress).color, AppPalette.light.textMuted); + expect(tester.widget(progress).backgroundColor, AppPalette.light.border); + expect(find.descendant(of: button, matching: find.text('Check for Updates')), findsOneWidget); + expect(find.text('正在检查'), findsNothing); + }); + + testWidgets('settings language uses a dropdown and saves the selected locale', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + final settingsController = FakeSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('settings-language-select'))); + await tester.pumpAndSettle(); + expect(find.text('English'), findsOneWidget); + expect(SettingsLanguageSelect.supportedValues, hasLength(21)); + expect(SettingsLanguageSelect.supportedValues, containsAll(['zh', 'zh_TW', 'pt'])); + expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN'))); + expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR'))); + expect(supportedLocaleFromConfig('zh_CN'), isNull); + expect(supportedLocaleFromConfig('pt_BR'), isNull); + expect(supportedLocaleFromConfig('zh'), const Locale('zh')); + expect(supportedLocaleFromConfig('zh_TW'), const Locale('zh', 'TW')); + expect(lookupAppLocalizations(const Locale('zh')).label, '简体中文'); + expect(lookupAppLocalizations(const Locale('zh', 'TW')).label, '繁體中文'); + + await tester.tap(find.text('English')); + await tester.pumpAndSettle(); + + expect(settingsController.state.requireValue.config.extra.locale, 'en'); + expect(find.text('English'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('theme settings adapt to mobile content layout', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 760)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Basic')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('theme-mode-system')), findsOneWidget); + expect(find.byKey(const ValueKey('theme-color-green')), findsOneWidget); + final languageSelect = find.byKey(const ValueKey('settings-language-select')); + expect(tester.getSize(languageSelect).height, 44); + + await tester.ensureVisible(languageSelect); + await tester.tap(languageSelect); + await tester.pumpAndSettle(); + final languagePopup = find.byType(shad.SelectPopup); + expect(languagePopup, findsOneWidget); + expect(tester.getSize(languagePopup).height, greaterThan(240)); + final englishOption = find.ancestor(of: find.text('English'), matching: find.byType(shad.Button)); + expect(tester.getSize(englishOption).height, greaterThanOrEqualTo(44)); + expect(tester.takeException(), isNull); + }); + + testWidgets('settings mobile back returns one level before requiring a second back to exit', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(390, 760)); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var systemPopCalls = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'SystemNavigator.pop') systemPopCalls++; + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + tasksControllerProvider.overrideWith(FakeTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Basic')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('theme-mode-system')), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('SETTINGS'), findsOneWidget); + expect(find.byKey(const ValueKey('theme-mode-system')), findsNothing); + expect(systemPopCalls, 0); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text('Press back again to exit'), findsOneWidget); + expect(systemPopCalls, 0); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(systemPopCalls, 1); + await tester.pump(const Duration(seconds: 6)); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('light task card hover uses a distinct semantic surface', (WidgetTester tester) async { + await _setTestSize(tester, const Size(760, 220)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'hover', name: 'hover.zip'), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: Offset.zero); + await gesture.moveTo(tester.getCenter(find.byType(TaskCard))); + await tester.pump(); + + final hoverSurface = tester.widget(find.byKey(const ValueKey('task-card-hover-surface'))); + expect(hoverSurface.color, AppPalette.light.taskCardHoverBg); + expect(hoverSurface.color, isNot(AppPalette.light.bg)); + expect(hoverSurface.color, isNot(AppPalette.light.cardBg)); + }); + + test('appearance controller initializes legacy configuration safely', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container + .read(appAppearanceControllerProvider.notifier) + .initialize(ExtraConfig(themeMode: 'dark', themeColor: 'purple')); + + final appearance = container.read(appAppearanceControllerProvider); + expect(appearance.themeMode, AppThemeMode.dark); + expect(appearance.themeColor, AppThemeColor.purple); + expect(AppThemeColor.fromKey('missing'), AppThemeColor.green); + }); + + test('theme colors map to shad primary and ring only', () { + final green = AppPalette.lightFor(AppThemeColor.green); + final purple = AppPalette.lightFor(AppThemeColor.purple); + final shadTheme = AppTheme.light(AppThemeColor.purple); + + expect(purple.brand, AppThemeColor.purple.light); + expect(purple.brand, isNot(green.brand)); + expect(purple.brandProgress, isNot(green.brandProgress)); + expect(purple.primaryActionBg, green.primaryActionBg); + expect(purple.primaryActionForeground, green.primaryActionForeground); + expect(shadTheme.colorScheme.primary, AppThemeColor.purple.light); + expect(shadTheme.colorScheme.ring, AppThemeColor.purple.light); + expect(shadTheme.colorScheme.accent, AppPalette.light.inputBg); + }); + + testWidgets('create task advanced options scroll smoothly into view', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 500)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pumpAndSettle(); + final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input')); + final createRenameInput = find.byKey(const ValueKey('create-task-rename-input')); + expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height); + final createDirectoryField = tester.widget(createDirectoryInput); + final createRenameField = tester.widget( + find.descendant(of: createRenameInput, matching: find.byType(shad.TextField)), + ); + expect(createDirectoryField.filled, createRenameField.filled); + expect(createDirectoryField.border, createRenameField.border); + expect(createDirectoryField.borderRadius, createRenameField.borderRadius); + expect(createDirectoryField.padding, createRenameField.padding); + await tester.ensureVisible(find.text('Advanced')); + await tester.pumpAndSettle(); + + final scrollView = tester.widget(find.byKey(const ValueKey('create-task-form-scroll'))); + final controller = scrollView.controller!; + expect((scrollView.padding! as EdgeInsets).bottom, 20); + final initialOffset = controller.offset; + + await tester.tap(find.text('Advanced')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 80)); + final movingOffset = controller.offset; + await tester.pump(const Duration(milliseconds: 300)); + final settledOffset = controller.offset; + + expect(movingOffset, greaterThan(initialOffset)); + expect(settledOffset, greaterThanOrEqualTo(movingOffset)); + expect(settledOffset, lessThanOrEqualTo(controller.position.maxScrollExtent)); + expect(find.text('Header'), findsOneWidget); + expect(find.text('HTTP headers'), findsNothing); + expect(find.text('Add'), findsNothing); + expect(find.text('Delete'), findsNothing); + expect(find.text('User-Agent'), findsOneWidget); + + final proxyMode = find.byKey(const ValueKey('create-task-proxy-mode')); + expect(tester.widget(proxyMode), isA>()); + expect(find.byKey(const ValueKey('create-task-proxy-mode-follow')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-proxy-mode-none')), findsOneWidget); + expect( + tester.getRect(find.byKey(const ValueKey('create-task-proxy-mode-follow'))).left, + closeTo(tester.getRect(createRenameInput).left, 0.01), + ); + final customProxy = find.byKey(const ValueKey('create-task-proxy-mode-custom')); + expect(customProxy, findsOneWidget); + await tester.tap(customProxy); + await tester.pump(); + expect(find.byKey(const ValueKey('create-task-proxy-scheme')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('create-task-proxy-mode-follow'))); + await tester.pump(); + expect(find.byKey(const ValueKey('create-task-proxy-scheme')), findsNothing); + + expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); + final bitTorrentTab = find.text('BitTorrent'); + await tester.ensureVisible(bitTorrentTab); + await tester.pumpAndSettle(); + await tester.tap(bitTorrentTab); + await tester.pump(); + expect(find.byKey(const ValueKey('create-task-auto-extract')), findsNothing); + await tester.tap(find.text('HTTP')); + await tester.pump(); + expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); + + final advancedContent = tester.widget(find.byKey(const ValueKey('create-task-advanced-content'))); + expect((advancedContent.padding as EdgeInsets).bottom, 4); + + final headerName = find.byKey(const ValueKey('create-task-http-header-name-0')); + final headerValue = find.byKey(const ValueKey('create-task-http-header-value-0')); + expect(tester.getSize(headerValue).width / tester.getSize(headerName).width, closeTo(1.618, 0.01)); + + var addHeader = find.byKey(const ValueKey('create-task-http-header-add')); + var lastRemoveHeader = find.byKey(const ValueKey('create-task-http-header-remove-2')); + expect(tester.getCenter(addHeader).dy, closeTo(tester.getCenter(lastRemoveHeader).dy, 0.01)); + expect(tester.getRect(addHeader).left, greaterThan(tester.getRect(lastRemoveHeader).right)); + + await tester.ensureVisible(addHeader); + await tester.pumpAndSettle(); + await tester.tap(addHeader); + await tester.pump(); + expect(find.byKey(const ValueKey('create-task-http-header-name-3')), findsOneWidget); + addHeader = find.byKey(const ValueKey('create-task-http-header-add')); + lastRemoveHeader = find.byKey(const ValueKey('create-task-http-header-remove-3')); + expect(tester.getCenter(addHeader).dy, closeTo(tester.getCenter(lastRemoveHeader).dy, 0.01)); + + for (var index = 3; index > 0; index--) { + final removeHeader = find.byKey(ValueKey('create-task-http-header-remove-$index')); + tester + .widget(find.descendant(of: removeHeader, matching: find.byType(shad.GhostButton))) + .onPressed!(); + await tester.pump(); + } + expect(find.byKey(const ValueKey('create-task-http-header-name-0')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-http-header-name-1')), findsNothing); + final onlyRemoveHeader = find.descendant( + of: find.byKey(const ValueKey('create-task-http-header-remove-0')), + matching: find.byType(shad.GhostButton), + ); + expect(tester.widget(onlyRemoveHeader).onPressed, isNull); + expect(find.byKey(const ValueKey('create-task-http-header-name-0')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 500)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pump(); + + expect(find.text('Mode'), findsNothing); + expect(find.text('Direct Download'), findsOneWidget); + expect(find.text('Skip resolving and create tasks immediately'), findsOneWidget); + final directDownloadCheckbox = find.descendant( + of: find.byKey(const ValueKey('create-task-direct-download-toggle')), + matching: find.byType(shad.Checkbox), + ); + expect( + tester.getRect(directDownloadCheckbox).left, + greaterThan(tester.getRect(find.text('Direct Download')).right), + ); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('create task directory attaches an outline picker to the input', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 500)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pump(); + + final directoryInput = find.byKey(const ValueKey('create-task-directory-input')); + final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker')); + expect(directoryInput, findsOneWidget); + expect(directoryPicker, findsOneWidget); + + await tester.enterText( + directoryInput, + '/storage/emulated/0/Download/a-very-long-create-task-directory-that-must-not-run-under-the-picker-icon', + ); + await tester.pump(); + + final editableText = find.descendant(of: directoryInput, matching: find.byType(EditableText)); + expect(tester.getRect(editableText).right, lessThan(tester.getRect(directoryPicker).left)); + expect(find.descendant(of: directoryInput, matching: directoryPicker), findsOneWidget); + expect(tester.getRect(directoryPicker).right, closeTo(tester.getRect(directoryInput).right, 0.01)); + expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('app focus outlines use the compact global border', (WidgetTester tester) async { + late shad.FocusOutlineTheme focusTheme; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(AppThemeColor.purple), + materialTheme: AppTheme.materialLight(AppThemeColor.purple), + home: AppComponentThemes( + child: Builder( + builder: (context) { + focusTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(focusTheme.align, 1); + expect(focusTheme.border?.top.width, 1); + }); + + testWidgets('task toolbar actions use native outline icon buttons', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 120)); + final searchController = TextEditingController(); + addTearDown(searchController.dispose); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(AppThemeColor.purple), + materialTheme: AppTheme.materialLight(AppThemeColor.purple), + home: TasksTopBar( + searchController: searchController, + onAddTask: () {}, + batchMode: true, + selectedBatchCount: 2, + canPauseSelected: true, + canResumeSelected: true, + onToggleBatchMode: () {}, + onPauseSelected: () {}, + onResumeSelected: () {}, + onDeleteSelected: () {}, + ), + ), + ); + await tester.pump(); + + final actions = tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('tasks-top-action-buttons')), + matching: find.byType(shad.IconButton), + ), + ) + .toList(growable: false); + expect(actions, hasLength(4)); + for (final action in actions) { + expect(action.variance, shad.ButtonVariance.outline); + } + expect( + find.descendant( + of: find.byKey(const ValueKey('tasks-top-action-buttons')), + matching: find.byType(shad.ButtonStyleOverride), + ), + findsNothing, + ); + }); + + testWidgets('resolved file selector uses aligned tree header and sortable columns', (WidgetTester tester) async { + await _setTestSize(tester, const Size(760, 620)); + var selected = []; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: ResolveFileTree( + files: [ + FileInfo(path: 'videos', name: 'clip.mp4', size: 1024), + FileInfo(path: 'docs', name: 'notes.txt', size: 128), + FileInfo(path: 'docs', name: 'manual.pdf', size: 256), + ], + initialSelection: const [0, 1, 2], + onSelectionChanged: (values) => selected = values, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final treeFinder = find.byWidgetPredicate((widget) => widget is VirtualTreeView); + expect(treeFinder, findsOneWidget); + final tree = tester.widget>(treeFinder); + expect(tree.branchLine, same(shad.BranchLine.path)); + expect( + tester.widgetList(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null), + isTrue, + ); + final checkboxes = find.byType(shad.Checkbox); + expect(tester.getTopLeft(checkboxes.at(0)).dx, closeTo(tester.getTopLeft(checkboxes.at(1)).dx, 0.5)); + final headerCenterY = tester.getRect(checkboxes.at(0)).center.dy; + expect( + tester.getRect(find.byKey(const ValueKey('resolve-tree-expand-toggle'))).center.dy, + closeTo(headerCenterY, 0.01), + ); + expect(tester.getRect(find.text('Name')).center.dy, closeTo(headerCenterY, 0.01)); + expect(tester.getRect(find.text('Size')).center.dy, closeTo(headerCenterY, 0.01)); + final expandTop = tester.getRect(find.byKey(const ValueKey('resolve-tree-expand-top'))); + final expandBottom = tester.getRect(find.byKey(const ValueKey('resolve-tree-expand-bottom'))); + expect((expandTop.top + expandBottom.bottom) / 2, closeTo(headerCenterY, 0.01)); + expect(find.text('Invert visible'), findsNothing); + expect(find.text('Clear'), findsNothing); + expect(find.byType(shad.TextField), findsNothing); + expect(find.byKey(const ValueKey('resolve-tree-expand-toggle')), findsOneWidget); + expect(find.byType(shad.OutlinedContainer), findsOneWidget); + expect(find.text('clip.mp4'), findsOneWidget); + expect(tester.widget(find.text('clip.mp4')).style?.color, AppPalette.light.textPrimary); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); + await tester.pumpAndSettle(); + expect(find.text('clip.mp4').hitTestable(), findsNothing); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); + await tester.pumpAndSettle(); + expect(find.text('clip.mp4'), findsOneWidget); + + final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:videos')); + expect(videosExpand, findsOneWidget); + expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:0')), findsOneWidget); + + await tester.tap(videosExpand); + await tester.pumpAndSettle(); + expect(tester.widget>(treeFinder).nodes.first.expanded, isFalse); + expect(find.text('clip.mp4').hitTestable(), findsNothing); + expect(find.text('videos').hitTestable(), findsOneWidget); + expect(selected, [0, 1, 2]); + + await tester.tap(find.text('videos')); + await tester.pump(); + expect(tester.widget>(treeFinder).nodes.first.expanded, isFalse); + expect(selected, [1, 2]); + + await tester.tap(find.text('videos')); + await tester.pump(); + expect(tester.widget>(treeFinder).nodes.first.expanded, isFalse); + expect(selected, [0, 1, 2]); + + await tester.tap(videosExpand); + await tester.pumpAndSettle(); + expect(tester.widget>(treeFinder).nodes.first.expanded, isTrue); + expect(find.text('clip.mp4'), findsOneWidget); + + BoxDecoration sortDecoration(String key) { + final decoration = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(DecoratedBox)); + return tester.widget(decoration).decoration as BoxDecoration; + } + + expect(sortDecoration('resolve-tree-sort-name-asc').color, isNull); + expect(sortDecoration('resolve-tree-sort-name-desc').color, isNull); + final nameCenterY = tester.getRect(find.text('Name')).center.dy; + final ascendingRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-sort-name-asc'))); + final descendingRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-sort-name-desc'))); + expect((ascendingRect.top + descendingRect.bottom) / 2, closeTo(nameCenterY, 0.01)); + + await tester.tap(find.text('Name')); + await tester.pumpAndSettle(); + expect(sortDecoration('resolve-tree-sort-name-asc').color, isNotNull); + expect(sortDecoration('resolve-tree-sort-name-desc').color, isNull); + + await tester.tap(find.text('Name')); + await tester.pumpAndSettle(); + expect(sortDecoration('resolve-tree-sort-name-asc').color, isNull); + expect(sortDecoration('resolve-tree-sort-name-desc').color, isNotNull); + + expect(find.text('notes.txt'), findsOneWidget); + expect(find.text('clip.mp4'), findsOneWidget); + expect(selected, [0, 1, 2]); + + tester.view.physicalSize = const Size(360, 620); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('file type filters support multiple selection and toggling off', (WidgetTester tester) async { + await _setTestSize(tester, const Size(760, 620)); + var selected = []; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: ResolveFileTree( + files: [ + FileInfo(path: '', name: 'clip.mp4', size: 100), + FileInfo(path: '', name: 'song.mp3', size: 200), + FileInfo(path: '', name: 'cover.png', size: 300), + ], + initialSelection: const [], + onSelectionChanged: (values) => selected = values, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + Color? filterBackground(String key) { + final decoration = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(DecoratedBox)); + return (tester.widget(decoration.first).decoration as BoxDecoration).color; + } + + await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video'))); + await tester.pump(); + expect(selected, [0]); + expect(filterBackground('resolve-tree-filter-video'), AppPalette.light.filterActiveBg); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-audio'))); + await tester.pump(); + expect(selected, [0, 1]); + expect(filterBackground('resolve-tree-filter-audio'), AppPalette.light.filterActiveBg); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video'))); + await tester.pump(); + expect(selected, [1]); + expect(filterBackground('resolve-tree-filter-video'), isNull); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-audio'))); + await tester.pump(); + expect(selected, isEmpty); + }); + + testWidgets('file name area scrolls horizontally while the trailing column stays fixed', (WidgetTester tester) async { + await _setTestSize(tester, const Size(420, 420)); + const longName = + 'this-is-a-very-long-file-name-that-needs-horizontal-scrolling-to-be-read-completely.archive.tar.gz'; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: ResolveFileTree( + files: [FileInfo(name: longName, size: 1024)], + initialSelection: const [0], + onSelectionChanged: (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final horizontalScroll = find.byKey(const ValueKey('resolve-tree-horizontal-scroll')); + final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:0')); + expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsOneWidget); + expect(horizontalScroll, findsOneWidget); + expect(trailing, findsOneWidget); + + final scrollable = find.descendant(of: horizontalScroll, matching: find.byType(Scrollable)); + final position = tester.state(scrollable).position; + expect(position.maxScrollExtent, greaterThan(0)); + final nameLeftBefore = tester.getRect(find.text(longName)).left; + final trailingLeftBefore = tester.getRect(trailing).left; + + await tester.drag(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), const Offset(-120, 0)); + await tester.pumpAndSettle(); + + expect(position.pixels, greaterThan(0)); + expect(tester.getRect(find.text(longName)).left, lessThan(nameLeftBefore)); + expect(tester.getRect(trailing).left, closeTo(trailingLeftBefore, 0.01)); + }); + + testWidgets('file name scrollbar stays hidden when measured labels fit', (WidgetTester tester) async { + await _setTestSize(tester, const Size(420, 420)); + const shortName = 'short.zip'; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: ResolveFileTree( + files: [FileInfo(name: shortName, size: 1024)], + initialSelection: const [0], + onSelectionChanged: (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text(shortName), findsOneWidget); + expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsNothing); + expect(find.byKey(const ValueKey('resolve-tree-horizontal-scroll')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('virtual tree lazily builds large file lists', (WidgetTester tester) async { + await _setTestSize(tester, const Size(760, 620)); + final files = List.generate( + 1200, + (index) => FileInfo(path: 'folder-${index ~/ 100}', name: 'file-$index.bin', size: index), + ); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: ResolveFileTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is VirtualTreeView), findsOneWidget); + expect(find.text('file-1199.bin'), findsNothing); + expect(find.byType(shad.Checkbox).evaluate().length, lessThan(100)); + final listView = tester.widget(find.byType(ListView)); + expect(listView.childrenDelegate, isA()); + + final scrollable = tester.state(find.byType(Scrollable)); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); + expect(find.text('file-1199.bin'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); + await tester.pump(const Duration(milliseconds: 75)); + expect(find.byType(shad.Checkbox).evaluate().length, lessThan(100)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + test('task runtime status parses selected file progress', () { + final status = api_task.TaskRuntimeStatus.fromJson({ + 'status': 'running', + 'used': 10, + 'speed': 20, + 'downloaded': 50, + 'total': 100, + 'uploadSpeed': 0, + 'uploaded': 0, + 'extractStatus': '', + 'extractProgress': 0, + 'files': [ + {'index': 3, 'size': 100, 'downloaded': 50}, + ], + }); + + expect(status.status, api_task.Status.running); + expect(status.files.single.index, 3); + expect(status.files.single.size, 100); + expect(status.files.single.downloaded, 50); + }); + + test('task transfer speeds include running downloads and active seeds', () { + final speeds = aggregateTaskTransferSpeeds([ + _apiTransferTask( + id: 'running-seed', + status: api_task.Status.running, + uploading: true, + speed: 100, + uploadSpeed: 10, + ), + _apiTransferTask( + id: 'completed-seed', + status: api_task.Status.done, + uploading: true, + speed: 999, + uploadSpeed: 20, + ), + _apiTransferTask(id: 'paused-stale', status: api_task.Status.pause, uploading: true, speed: 999, uploadSpeed: 30), + _apiTransferTask( + id: 'running-download', + status: api_task.Status.running, + uploading: false, + speed: 50, + uploadSpeed: 40, + ), + ]); + + expect(speeds.downloadBytesPerSecond, 150); + expect(speeds.uploadBytesPerSecond, 30); + }); + + test('task records keep a missing resource size unknown', () { + final task = _apiTransferTask( + id: 'unknown-size', + status: api_task.Status.running, + uploading: false, + speed: 0, + uploadSpeed: 0, + ); + task.progress.used = 1704000000000000000; + + final record = TaskRecord.fromApi(task); + + expect(record.total, isNull); + expect(record.progress, isNull); + expect(record.isIndeterminate, isTrue); + }); + + test('task records preserve zero upload speed while uploading', () { + final record = TaskRecord.fromApi( + _apiTransferTask(id: 'seeding', status: api_task.Status.done, uploading: true, speed: 0, uploadSpeed: 0), + ); + + expect(record.uploading, isTrue); + expect(record.uploadSpeed, '0 B/s'); + }); + + test('task records expose the Go download duration in nanoseconds', () { + final task = _apiTransferTask( + id: 'completed-duration', + status: api_task.Status.done, + uploading: false, + speed: 0, + uploadSpeed: 0, + ); + task.progress.used = 3500000000; + + final record = TaskRecord.fromApi(task); + expect(record.downloadDuration, const Duration(milliseconds: 3500)); + expect(record.createdAt, DateTime.fromMillisecondsSinceEpoch(0)); + }); + + test('task URL updates are limited to paused or failed HTTP tasks', () { + expect(_taskRecord(id: 'http-paused', name: 'paused.bin', status: TaskStatus.paused).canUpdateUrl, isTrue); + expect(_taskRecord(id: 'http-failed', name: 'failed.bin', status: TaskStatus.failed).canUpdateUrl, isTrue); + expect(_taskRecord(id: 'http-running', name: 'running.bin').canUpdateUrl, isFalse); + expect( + _taskRecord( + id: 'bt-paused', + name: 'paused.torrent', + status: TaskStatus.paused, + protocol: api_task.Protocol.bt, + ).canUpdateUrl, + isFalse, + ); + }); + + test('task icons follow task type and file extension instead of status', () { + final extensionCases = <(String, IconData)>[ + ('setup.exe', GopeedIcons.fileInstaller), + ('mobile.apk', GopeedIcons.fileAndroid), + ('mobile.ipa', GopeedIcons.fileIos), + ('backup.iso', GopeedIcons.fileDiskImage), + ('index.html', GopeedIcons.fileWeb), + ('notes.md', GopeedIcons.fileText), + ('manual.pdf', GopeedIcons.filePdf), + ('report.docx', GopeedIcons.fileDocument), + ('budget.xlsx', GopeedIcons.fileSpreadsheet), + ('slides.pptx', GopeedIcons.filePresentation), + ('source.tar.gz', GopeedIcons.fileArchive), + ('photo.PNG', GopeedIcons.fileImage), + ('track.flac', GopeedIcons.fileAudio), + ('movie.MKV', GopeedIcons.fileVideo), + ('main.dart', GopeedIcons.fileCode), + ('book.epub', GopeedIcons.fileEbook), + ('display.woff2', GopeedIcons.fileFont), + ('cache.sqlite', GopeedIcons.fileDatabase), + ('linux.torrent', GopeedIcons.protocolBt), + ]; + for (final (name, icon) in extensionCases) { + expect( + _taskRecord(id: name, name: name).icon, + icon, + reason: name, + ); + } + + expect(_taskRecord(id: 'folder', name: 'Downloads', isFolder: true).icon, GopeedIcons.folder); + expect( + _taskRecord(id: 'bt-folder', name: 'Linux collection', isFolder: true, protocol: api_task.Protocol.bt).icon, + GopeedIcons.folderBt, + ); + expect(_taskRecord(id: 'file', name: 'unknown').icon, GopeedIcons.file); + expect(_taskRecord(id: 'bt', name: 'unknown', protocol: api_task.Protocol.bt).icon, GopeedIcons.protocolBt); + expect(_taskRecord(id: 'ed2k', name: 'unknown', protocol: api_task.Protocol.ed2k).icon, GopeedIcons.protocolEd2k); + final runningIcon = _taskRecord(id: 'running-video', name: 'movie.mp4').icon; + final failedIcon = _taskRecord(id: 'failed-video', name: 'movie.mp4', status: TaskStatus.failed).icon; + expect(failedIcon, runningIcon); + }); + + testWidgets('task cards label a missing total as unknown size', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + final record = TaskRecord.fromApi( + _apiTransferTask( + id: 'unknown-size-card', + status: api_task.Status.running, + uploading: false, + speed: 0, + uploadSpeed: 0, + ), + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: record, + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Unknown size'), findsOneWidget); + expect(find.textContaining('TB'), findsNothing); + }); + + testWidgets('task cards display zero upload speed while uploading', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + for (final status in [api_task.Status.running, api_task.Status.done]) { + final record = TaskRecord.fromApi( + _apiTransferTask(id: 'uploading-${status.name}', status: status, uploading: true, speed: 0, uploadSpeed: 0), + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: record, + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('0 B/s'), findsOneWidget); + expect(find.byIcon(Icons.north), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('completed task cards omit progress and keep size left with speed and status right', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1024, 220)); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'completed-card', name: 'completed.zip', status: TaskStatus.completed), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Completed'), findsOneWidget); + expect(find.text('COMPLETED'), findsNothing); + expect(find.text('100 B'), findsOneWidget); + expect(find.text('10 B/s'), findsOneWidget); + final sizeRect = tester.getRect(find.text('100 B')); + final speedRect = tester.getRect(find.text('10 B/s')); + final statusRect = tester.getRect(find.text('Completed')); + expect(sizeRect.left, lessThan(speedRect.left)); + expect(speedRect.right, lessThan(statusRect.left)); + expect(find.byType(TaskProgressBar), findsNothing); + expect(find.text('Downloaded'), findsNothing); + }); + + testWidgets('failed task cards show the shared failed label without an error reason', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + const task = TaskRecord( + id: 'failed-card', + name: 'failed.zip', + status: TaskStatus.failed, + downloaded: '50 B', + total: '100 B', + speed: '10 B/s', + progress: 0.5, + url: 'https://example.com/failed.zip', + storagePath: '/downloads/failed.zip', + files: [], + uploading: false, + error: 'Connection refused', + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: task, + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Failed'), findsOneWidget); + expect(find.text('Connection refused'), findsNothing); + expect(find.text('10 B/s'), findsNothing); + expect(tester.getRect(find.text('50 B / 100 B')).left, lessThan(tester.getRect(find.text('Failed')).left)); + }); + + testWidgets('speed monitor displays supplied task totals without simulated updates', (WidgetTester tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const SizedBox( + width: 192, + child: SpeedMonitorCard(downloadBytesPerSecond: 10 * 1024 * 1024, uploadBytesPerSecond: 2 * 1024 * 1024), + ), + ), + ); + await tester.pump(const Duration(seconds: 2)); + + expect(find.text('10'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('MB/s'), findsNWidgets(2)); + }); + + testWidgets('speed monitor adapts units for low transfer rates', (WidgetTester tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const SizedBox( + width: 192, + child: SpeedMonitorCard(downloadBytesPerSecond: 96 * 1024, uploadBytesPerSecond: 512), + ), + ), + ); + + expect(find.text('96'), findsOneWidget); + expect(find.text('KB/s'), findsOneWidget); + expect(find.text('512'), findsOneWidget); + expect(find.text('B/s'), findsOneWidget); + }); + + testWidgets('task file tree maps sparse file progress by resource index', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 620)); + const task = TaskRecord( + id: 'task-1', + name: 'album', + status: TaskStatus.downloading, + downloaded: '50 B', + total: '100 B', + progress: 0.5, + url: 'https://example.com/album.torrent', + storagePath: '/downloads/album', + files: [ + TaskFileNode(path: 'disc', name: 'skipped.mp3', sizeBytes: 100), + TaskFileNode(path: 'disc', name: 'selected.mp3', sizeBytes: 100), + ], + uploading: false, + ); + final status = api_task.TaskRuntimeStatus( + status: api_task.Status.running, + used: 10, + speed: 20, + downloaded: 50, + total: 100, + uploadSpeed: 0, + uploaded: 0, + extractStatus: api_task.ExtractStatus.none, + extractProgress: 0, + files: [api_task.FileRuntimeStatus(index: 1, size: 100, downloaded: 50)], + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskFileTree(task: task, runtimeStatus: status), + ), + ), + ); + await tester.pump(); + + expect(find.text('Progress'), findsNWidgets(2)); + expect(find.text('Size'), findsOneWidget); + expect(find.text('skipped.mp3'), findsOneWidget); + expect(find.text('selected.mp3'), findsOneWidget); + expect(find.byIcon(GopeedIcons.folder), findsOneWidget); + expect(find.byIcon(GopeedIcons.fileAudio), findsNWidgets(2)); + expect(tester.widget(find.text('selected.mp3')).style?.fontSize, 11); + final compactTree = tester.widget>( + find.byWidgetPredicate((widget) => widget is VirtualTreeView), + ); + expect(compactTree.rowHeight, 27); + expect(find.byKey(const ValueKey('task-file-progress-file:0')), findsNothing); + final selectedProgress = find.byKey(const ValueKey('task-file-progress-file:1')); + expect(selectedProgress, findsOneWidget); + final circularProgress = find.descendant( + of: selectedProgress, + matching: find.byType(shad.CircularProgressIndicator), + ); + expect(circularProgress, findsOneWidget); + expect(tester.widget(circularProgress).value, 0.5); + expect(find.descendant(of: selectedProgress, matching: find.text('100 B')), findsOneWidget); + expect(find.byType(shad.Checkbox), findsNothing); + }); + + testWidgets('task file tree keeps a progress ring while per-file status is unavailable', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 420)); + const task = TaskRecord( + id: 'task-fallback-progress', + name: 'pending.bin', + status: TaskStatus.downloading, + downloaded: '25 B', + total: '100 B', + progress: 0.25, + url: 'https://example.com/pending.bin', + storagePath: '/downloads/pending.bin', + files: [TaskFileNode(path: '', name: 'pending.bin', sizeBytes: 100)], + uploading: false, + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(24), + child: TaskFileTree(task: task), + ), + ), + ); + await tester.pump(); + + final fileProgress = find.byKey(const ValueKey('task-file-progress-file:0')); + final ring = find.descendant(of: fileProgress, matching: find.byType(shad.CircularProgressIndicator)); + expect(ring, findsOneWidget); + expect(tester.widget(ring).value, 0.25); + }); + + testWidgets('completed task files use a completion icon instead of a progress ring', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 420)); + const task = TaskRecord( + id: 'task-completed', + name: 'completed.bin', + status: TaskStatus.completed, + downloaded: '100 B', + total: '100 B', + progress: 1, + url: 'https://example.com/completed.bin', + storagePath: '/downloads/completed.bin', + files: [TaskFileNode(path: '', name: 'completed.bin', sizeBytes: 100)], + uploading: false, + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(24), + child: TaskFileTree(task: task), + ), + ), + ); + + expect(find.byIcon(shad.BootstrapIcons.checkCircleFill), findsOneWidget); + expect(find.byType(shad.CircularProgressIndicator), findsNothing); + }); + + testWidgets('paused and failed task files use state icons instead of progress rings', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 420)); + const pausedTask = TaskRecord( + id: 'task-paused', + name: 'paused.bin', + status: TaskStatus.paused, + downloaded: '40 B', + total: '100 B', + progress: 0.4, + url: 'https://example.com/paused.bin', + storagePath: '/downloads/paused.bin', + files: [TaskFileNode(path: '', name: 'paused.bin', sizeBytes: 100)], + uploading: false, + ); + const failedTask = TaskRecord( + id: 'task-failed', + name: 'failed.bin', + status: TaskStatus.failed, + downloaded: '40 B', + total: '100 B', + progress: 0.4, + url: 'https://example.com/failed.bin', + storagePath: '/downloads/failed.bin', + files: [TaskFileNode(path: '', name: 'failed.bin', sizeBytes: 100)], + uploading: false, + ); + + Future pumpTask(TaskRecord task) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskFileTree(task: task), + ), + ), + ); + await tester.pump(); + } + + await pumpTask(pausedTask); + expect(find.byIcon(Icons.pause_circle_filled), findsOneWidget); + expect(find.byType(shad.CircularProgressIndicator), findsNothing); + + await pumpTask(failedTask); + expect(find.byIcon(Icons.error_rounded), findsOneWidget); + expect(find.byType(shad.CircularProgressIndicator), findsNothing); + }); + + testWidgets('task drawer aligns information labels beside their values', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + final task = _taskRecord( + id: 'details', + name: 'details.zip', + status: TaskStatus.completed, + remaining: '1 minute remaining', + downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3), + createdAt: DateTime(2026, 8, 27, 12, 34, 56), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + taskRuntimeStatusProvider(task.id).overrideWith( + (ref) async => api_task.TaskRuntimeStatus( + status: api_task.Status.done, + used: 0, + speed: 0, + downloaded: 100, + total: 100, + uploadSpeed: 0, + uploaded: 0, + files: const [], + ), + ), + ], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Stack( + children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final labelTopLeft = tester.getTopLeft(find.text('Name')); + final valueTopLeft = tester.getTopLeft(find.text('details.zip')); + expect((labelTopLeft.dy - valueTopLeft.dy).abs(), lessThan(5)); + expect(valueTopLeft.dx, greaterThan(labelTopLeft.dx)); + + final storageLabelTopLeft = tester.getTopLeft(find.text('Storage Path')); + final storageValueFinder = find.byKey(const ValueKey('task-details-storage-value')); + final storageValueTopLeft = tester.getTopLeft(storageValueFinder); + expect((storageLabelTopLeft.dy - storageValueTopLeft.dy).abs(), lessThan(5)); + expect(storageValueTopLeft.dx, greaterThan(storageLabelTopLeft.dx)); + final storageValue = tester.widget(storageValueFinder); + expect(storageValue.data, contains('\u200B')); + expect(storageValue.data!.replaceAll('\u200B', ''), '/downloads/details.zip'); + expect(storageValue.semanticsLabel, '/downloads/details.zip'); + final urlValue = tester.widget(find.byKey(const ValueKey('task-details-url-value'))); + expect(urlValue.data, contains('\u200B')); + expect(urlValue.data!.replaceAll('\u200B', ''), task.url); + expect(urlValue.semanticsLabel, task.url); + expect(find.text('Task Details'), findsOneWidget); + expect(find.text('Task Name'), findsNothing); + final detailHeader = tester.widget( + find.descendant(of: find.byKey(const ValueKey('app-detail-drawer-header')), matching: find.byType(DecoratedBox)), + ); + expect(((detailHeader.decoration as BoxDecoration).border! as Border).bottom.width, 1); + expect(find.text('Details'), findsOneWidget); + expect(find.text('Files'), findsOneWidget); + expect(find.text('Statistics'), findsOneWidget); + expect(find.text('Remaining'), findsNothing); + expect(find.text('Download duration'), findsOneWidget); + expect(find.text('2 hours'), findsOneWidget); + expect(find.text('100 B'), findsOneWidget); + expect(find.text('50 B / 100 B'), findsNothing); + expect(find.text('Created'), findsOneWidget); + expect(find.text('2026-08-27 12:34:56'), findsOneWidget); + final infoDivider = find.byKey(const ValueKey('task-details-info-divider')); + expect(tester.getBottomLeft(find.text('Created')).dy, lessThan(tester.getTopLeft(infoDivider).dy)); + expect(tester.getBottomLeft(infoDivider).dy, lessThan(tester.getTopLeft(find.text('Download Link')).dy)); + expect(find.text('1 minute remaining'), findsNothing); + expect(find.text('Completed'), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('task-details-drawer'))).width, + AppDesignTokens.taskDetailsDrawerMinWidth, + ); + + tester.view.physicalSize = const Size(1600, 900); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byKey(const ValueKey('task-details-drawer'))).width, + 1600 * AppDesignTokens.taskDetailsDrawerViewportRatio, + ); + + await tester.tap(find.text('Files')); + await tester.pump(); + expect(find.text('Progress'), findsWidgets); + expect(find.text('Task progress'), findsNothing); + final browseFilesAction = find.byKey(const ValueKey('task-files-browse')); + expect(browseFilesAction, findsOneWidget); + final totalProgress = find.byKey(const ValueKey('task-files-total-progress')); + expect(totalProgress, findsOneWidget); + expect(tester.getTopLeft(browseFilesAction).dx, greaterThan(tester.getTopRight(totalProgress).dx)); + expect( + find.descendant(of: browseFilesAction, matching: find.byIcon(Icons.snippet_folder_outlined)), + findsOneWidget, + ); + final browseFilesTooltipFinder = find.descendant(of: browseFilesAction, matching: find.byType(AppTooltip)); + expect(tester.widget(browseFilesTooltipFinder).message, 'Browse Files'); + + await tester.tap(browseFilesAction); + await tester.pumpAndSettle(); + final browserDialog = find.byKey(const ValueKey('task-file-browser-dialog')); + expect(browserDialog, findsOneWidget); + expect(find.descendant(of: browserDialog, matching: find.byType(TaskFileManagerView)), findsOneWidget); + expect(tester.getCenter(browserDialog).dx, closeTo(tester.view.physicalSize.width / 2, 1)); + expect(tester.getCenter(browserDialog).dy, closeTo(tester.view.physicalSize.height / 2, 1)); + }); + + testWidgets('paused task details show a dash for remaining time', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + final task = _taskRecord(id: 'paused-details', name: 'paused.zip', status: TaskStatus.paused); + + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskDetailsView(task: task, mobile: false, onOpenStorage: () {}, onUpdateUrl: (_) async {}), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Pause'), findsOneWidget); + expect(find.text('Remaining'), findsOneWidget); + expect(find.text('—'), findsOneWidget); + }); + + testWidgets('failed task details omit the unavailable error reason', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + final task = TaskRecord( + id: 'failed-details', + name: 'failed.zip', + status: TaskStatus.failed, + downloaded: '50 B', + total: '100 B', + progress: 0.5, + url: 'https://example.com/failed.zip', + storagePath: '/downloads/failed.zip', + files: const [], + uploading: false, + error: 'Connection refused', + createdAt: DateTime(2026, 8, 27, 12, 34, 56), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + taskRuntimeStatusProvider(task.id).overrideWith( + (ref) async => api_task.TaskRuntimeStatus( + status: api_task.Status.error, + used: 0, + speed: 0, + downloaded: 50, + total: 100, + uploadSpeed: 0, + uploaded: 0, + files: const [], + ), + ), + ], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Stack( + children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final failedStatus = find.text('Failed'); + expect(failedStatus, findsOneWidget); + expect(tester.widget(failedStatus).style?.color, AppPalette.of(tester.element(failedStatus)).error); + expect(find.text('Created'), findsOneWidget); + expect(find.text('Error'), findsNothing); + expect(find.text('Connection refused'), findsNothing); + }); + + testWidgets('task drawer actions use icon buttons with hover labels', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await _setTestSize(tester, const Size(900, 700)); + final task = _taskRecord(id: 'editable-details', name: 'editable.zip', status: TaskStatus.paused); + + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Stack( + children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (final key in ['task-details-edit-url', 'task-details-copy-url', 'task-details-open-storage']) { + final action = find.byKey(ValueKey(key)); + expect(action, findsOneWidget); + expect(find.descendant(of: action, matching: find.byType(shad.IconButton)), findsOneWidget); + expect(find.descendant(of: action, matching: find.byType(AppTooltip)), findsOneWidget); + } + expect(find.byKey(const ValueKey('task-details-close')), findsNothing); + expect( + find.descendant(of: find.byKey(const ValueKey('task-details-drawer')), matching: find.byIcon(Icons.close)), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('app-detail-drawer-close-button')), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Edit'), findsNothing); + expect(find.text('Open'), findsNothing); + + final editAction = find.byKey(const ValueKey('task-details-edit-url')); + final editTooltipFinder = find.descendant(of: editAction, matching: find.byType(AppTooltip)); + expect(tester.widget(editTooltipFinder).message, 'Edit'); + await tester.tap(editAction); + await tester.pumpAndSettle(); + for (final key in ['task-details-cancel-url', 'task-details-save-url']) { + final action = find.byKey(ValueKey(key)); + expect(action, findsOneWidget); + expect(find.descendant(of: action, matching: find.byType(shad.IconButton)), findsOneWidget); + expect(find.descendant(of: action, matching: find.byType(AppTooltip)), findsOneWidget); + } + expect(find.text('Cancel'), findsNothing); + expect(find.text('Save'), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('mobile task details copy the storage path instead of opening it', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await _setTestSize(tester, const Size(390, 700)); + final task = _taskRecord(id: 'mobile-storage', name: 'mobile.zip', status: TaskStatus.completed); + var openedStorage = false; + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = (call.arguments as Map)['text'] as String?; + } + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskDetailsView( + task: task, + mobile: true, + onOpenStorage: () => openedStorage = true, + onUpdateUrl: (_) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final copyAction = find.byKey(const ValueKey('task-details-copy-storage')); + expect(copyAction, findsOneWidget); + expect(find.byKey(const ValueKey('task-details-open-storage')), findsNothing); + expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.copy_outlined)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message, + 'Copy', + ); + + await tester.ensureVisible(copyAction); + await tester.pumpAndSettle(); + tester + .widget(find.descendant(of: copyAction, matching: find.byType(shad.IconButton))) + .onPressed!(); + await tester.pumpAndSettle(); + + expect(openedStorage, isFalse); + expect(clipboardText, task.storagePath); + expect(find.descendant(of: copyAction, matching: find.byIcon(Icons.check)), findsOneWidget); + expect( + tester.widget(find.descendant(of: copyAction, matching: find.byType(AppTooltip))).message, + 'Copied', + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('task drawer hides URL editing for non-HTTP tasks', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + final task = _taskRecord( + id: 'bt-details', + name: 'paused.torrent', + status: TaskStatus.paused, + protocol: api_task.Protocol.bt, + ); + + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Stack( + children: [TaskDrawer(task: task, onClose: () {}, onOpenStorage: () {}, onUpdateUrl: (_) async {})], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-details-edit-url')), findsNothing); + expect(find.byKey(const ValueKey('task-details-copy-url')), findsOneWidget); + }); + + testWidgets('mobile task details use a standalone route without bottom navigation', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 760)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(RecordingTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(PrimaryBottomNavigation), findsOneWidget); + await tester.tap(find.text('first.zip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Task Details'), findsOneWidget); + expect(find.text('Details'), findsOneWidget); + expect(find.text('Files'), findsOneWidget); + expect(find.text('Statistics'), findsOneWidget); + expect(find.byType(PrimaryBottomNavigation), findsNothing); + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + + await tester.tap(find.text('Files')); + await tester.pump(); + expect(find.byKey(const ValueKey('task-files-browse')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('task-files-browse'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(TaskFileManagerView), findsOneWidget); + expect(find.text('first.zip'), findsWidgets); + }); + + testWidgets('task file manager uses flat directories and platform-specific file actions', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(390, 700)); + final task = _taskRecord( + id: 'managed-files', + name: 'bundle', + status: TaskStatus.completed, + isFolder: true, + files: const [ + TaskFileNode(path: '/', name: 'root.txt', sizeBytes: 128), + TaskFileNode(path: '/docs', name: 'guide.pdf', sizeBytes: 1024), + ], + ); + + Future pumpManager({required bool webActions, required bool desktopActions}) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskFileManagerView(task: task, webActions: webActions, desktopActions: desktopActions), + ), + ); + await tester.pump(); + } + + await pumpManager(webActions: true, desktopActions: false); + expect(find.text('root.txt'), findsOneWidget); + expect(find.text('docs'), findsOneWidget); + expect(find.text('guide.pdf'), findsNothing); + expect(find.byIcon(GopeedIcons.fileText), findsOneWidget); + expect(find.byIcon(GopeedIcons.folder), findsOneWidget); + expect(find.byIcon(Icons.open_in_new), findsOneWidget); + final openFileTooltip = find.ancestor(of: find.byIcon(Icons.open_in_new), matching: find.byType(AppTooltip)); + expect(tester.widget(openFileTooltip).message, 'Open File'); + expect(find.byIcon(Icons.download_outlined), findsOneWidget); + expect(find.byIcon(Icons.share_outlined), findsNothing); + + await tester.tap(find.text('docs')); + await tester.pump(); + expect(find.text('guide.pdf'), findsOneWidget); + expect(find.byIcon(GopeedIcons.filePdf), findsOneWidget); + expect(find.text('1 items'), findsNothing); + expect(find.byKey(const ValueKey('file-breadcrumb-/docs')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('file-breadcrumb-/'))); + await tester.pump(); + expect(find.text('1 items'), findsOneWidget); + + await pumpManager(webActions: false, desktopActions: false); + expect(find.byIcon(Icons.download_outlined), findsNothing); + expect(find.byIcon(Icons.share_outlined), findsOneWidget); + + await pumpManager(webActions: false, desktopActions: true); + expect(find.byIcon(Icons.download_outlined), findsNothing); + expect(find.byIcon(Icons.share_outlined), findsNothing); + expect(find.byIcon(Icons.open_in_new), findsOneWidget); + expect(find.byIcon(Icons.folder_open_outlined), findsNWidgets(2)); + }); + + testWidgets('open desktop task details follow refreshed file metadata', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + late RefreshingTaskFilesController tasksController; + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(() => tasksController = RefreshingTaskFilesController()), + ], + child: const GopeedApp(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.text('fresh.zip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.text('Files')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('late-file.bin'), findsNothing); + + tasksController.publishFiles(); + await tester.pump(); + + expect(find.text('late-file.bin'), findsOneWidget); + }); + + testWidgets('mobile task cards use compact vertical spacing', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 240)); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: ListView( + children: [ + TaskCard( + task: _taskRecord(id: 'mobile-compact', name: 'mobile.zip'), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ], + ), + ), + ); + + expect(tester.getSize(find.byKey(const ValueKey('task-card-mobile-surface'))).height, lessThan(96)); + }); + + testWidgets('piece map stays within narrow layouts and exposes a tapped range', (WidgetTester tester) async { + await _setTestSize(tester, const Size(280, 640)); + final pieces = List.generate( + 5000, + (index) => index < 2100 ? TaskPieceState.completed : TaskPieceState.pending, + ); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(16), + child: PieceMap(pieceMap: TaskPieceMap.fromStates(pieces, pieceSize: 1024)), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + expect(tester.takeException(), isNull); + final piecePaint = find.descendant(of: find.byType(PieceMap), matching: find.byType(CustomPaint)); + expect(tester.getSize(piecePaint).height, lessThan(240)); + await tester.tapAt(tester.getTopLeft(piecePaint) + const Offset(5, 5)); + await tester.pump(); + expect(find.textContaining('Pieces 1'), findsOneWidget); + }); + + testWidgets('large piece map fills complete rows at the default drawer width', (WidgetTester tester) async { + await _setTestSize(tester, const Size(440, 640)); + final pieces = List.filled(5000, TaskPieceState.pending); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: PieceMap(pieceMap: TaskPieceMap.fromStates(pieces, pieceSize: 1024)), + ), + ), + ); + await tester.pump(); + + final piecePaint = find.descendant(of: find.byType(PieceMap), matching: find.byType(CustomPaint)); + expect(tester.getSize(piecePaint).width, 392); + // The fixed 252-cell cap fills 9 rows at the default 28-column width. + expect(tester.getSize(piecePaint).height, closeTo(123.96, 0.1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('BT statistics show upload, share ratio, and share duration on the second row', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(440, 760)); + final stats = BtTaskStats( + totalPeers: 24, + activePeers: 6, + connectedSeeders: 4, + connectedLeechers: 2, + seedBytes: 1536, + seedRatio: 1.25, + seedTime: 3723, + peers: const [], + pieceMap: TaskPieceMap.fromStates(const [TaskPieceState.completed, TaskPieceState.pending], pieceSize: 1024), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [taskStatsProvider.overrideWith((ref, request) async => stats)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskStatisticsTab( + task: _taskRecord(id: 'bt-statistics', name: 'archive.torrent', protocol: api_task.Protocol.bt), + mobile: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Downloaded'), findsNothing); + expect(find.text('Uploaded'), findsOneWidget); + expect(find.text('Share Ratio'), findsOneWidget); + expect(find.text('Share Duration'), findsOneWidget); + expect(find.text('2 hours'), findsOneWidget); + + final uploadRect = tester.getRect(find.text('Uploaded')); + final ratioRect = tester.getRect(find.text('Share Ratio')); + final durationRect = tester.getRect(find.text('Share Duration')); + expect(uploadRect.top, closeTo(ratioRect.top, 0.01)); + expect(ratioRect.top, closeTo(durationRect.top, 0.01)); + expect(uploadRect.center.dx, lessThan(ratioRect.center.dx)); + expect(ratioRect.center.dx, lessThan(durationRect.center.dx)); + expect(tester.takeException(), isNull); + }); + + testWidgets('empty peer table keeps its sortable column headers', (WidgetTester tester) async { + await _setTestSize(tester, const Size(440, 500)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(16), + child: PeerTable(protocol: PeerTableProtocol.bt, peers: []), + ), + ), + ); + await tester.pumpAndSettle(); + + for (final header in [ + 'Address', + 'Client', + 'Protocol', + 'Speed', + 'Upload speed', + 'Pieces', + 'Progress', + 'File relevance', + 'Source', + ]) { + expect(find.text(header), findsOneWidget); + } + expect(find.text('No connections'), findsNothing); + expect(find.byType(Scrollbar), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('peer table displays the remote completion percentage', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 500)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(16), + child: PeerTable( + protocol: PeerTableProtocol.bt, + peers: [ + TaskPeerStats( + address: '127.0.0.1:6881', + client: 'Gopeed', + downloadSpeed: 2048, + uploadSpeed: 1024, + pieceCount: 3, + completion: 0.375, + relevance: 0.125, + source: 'pex', + transport: 'utp', + ), + TaskPeerStats( + address: '127.0.0.2:6881', + client: 'Gopeed', + downloadSpeed: 1024, + uploadSpeed: 512, + pieceCount: 8, + completion: 1, + relevance: 0, + source: 'dht', + transport: 'tcp', + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Progress'), findsOneWidget); + expect(find.text('File relevance'), findsOneWidget); + expect(find.text('37.5%'), findsOneWidget); + expect(find.text('12.5%'), findsOneWidget); + expect(find.text('uTP'), findsOneWidget); + expect(find.text('BT'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('ED2K peer table keeps protocol-specific columns', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 500)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(16), + child: PeerTable( + protocol: PeerTableProtocol.ed2k, + peers: [ + TaskPeerStats( + address: '127.0.0.1:4662', + client: 'aMule', + downloadSpeed: 2048, + uploadSpeed: 1024, + pieceCount: 3, + completion: null, + relevance: null, + source: 'kad', + transport: 'tcp', + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('ED2K'), findsOneWidget); + expect(find.text('Progress'), findsNothing); + expect(find.text('File relevance'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('HTTP connection lanes keep metadata and progress on one compact row', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 640)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(16), + child: HttpConnectionLanes( + taskDownloading: true, + connections: const [ + HttpConnectionStats( + downloaded: 32 * 1024 * 1024, + total: 64 * 1024 * 1024, + completed: false, + failed: false, + retryTimes: 0, + ), + HttpConnectionStats( + downloaded: 12 * 1024 * 1024, + total: 64 * 1024 * 1024, + completed: false, + failed: false, + retryTimes: 2, + ), + HttpConnectionStats( + downloaded: 48 * 1024 * 1024, + total: 64 * 1024 * 1024, + completed: true, + failed: false, + retryTimes: 0, + ), + HttpConnectionStats( + downloaded: 8 * 1024 * 1024, + total: 64 * 1024 * 1024, + completed: false, + failed: true, + retryTimes: 3, + ), + ], + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + final firstLane = find.byKey(const ValueKey('http-connection-lane-0')); + final secondLane = find.byKey(const ValueKey('http-connection-lane-1')); + final thirdLane = find.byKey(const ValueKey('http-connection-lane-2')); + final fourthLane = find.byKey(const ValueKey('http-connection-lane-3')); + expect(tester.getSize(firstLane).height, 38); + expect(tester.getSize(secondLane).height, 38); + expect(tester.getSize(thirdLane).height, 38); + expect(tester.getSize(fourthLane).height, 38); + expect(find.text('#1'), findsOneWidget); + expect(find.text('Downloading'), findsNothing); + expect(find.text('Retrying'), findsNothing); + expect(find.text('重试 2 次'), findsNothing); + expect(find.text('Completed'), findsNothing); + final firstProgress = find.byKey(const ValueKey('http-connection-progress-0')); + final secondProgress = find.byKey(const ValueKey('http-connection-progress-1')); + final thirdProgress = find.byKey(const ValueKey('http-connection-progress-2')); + final fourthProgress = find.byKey(const ValueKey('http-connection-progress-3')); + expect(tester.getSize(firstProgress).width, tester.getSize(secondProgress).width); + expect(tester.getSize(secondProgress).width, tester.getSize(thirdProgress).width); + expect(tester.getSize(thirdProgress).width, tester.getSize(fourthProgress).width); + expect(tester.widget(firstProgress).shimmer, isTrue); + expect(tester.widget(firstProgress).indeterminate, isFalse); + expect(tester.widget(firstProgress).value, 0.5); + expect(tester.widget(secondProgress).shimmer, isFalse); + expect(tester.widget(secondProgress).indeterminate, isFalse); + expect(tester.widget(thirdProgress).shimmer, isFalse); + expect(tester.widget(thirdProgress).value, 0.75); + expect(tester.widget(fourthProgress).shimmer, isFalse); + expect(tester.widget(fourthProgress).value, 0.125); + final retryIcon = find.byKey(const ValueKey('http-connection-status-1')); + final retryTooltip = find.descendant(of: retryIcon, matching: find.byType(AppTooltip)); + expect(tester.widget(retryTooltip).message, 'Retrying · 2 retries'); + final failedIcon = find.byKey(const ValueKey('http-connection-status-3')); + expect(find.descendant(of: failedIcon, matching: find.byType(AppTooltip)), findsOneWidget); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(retryIcon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.text('Retrying · 2 retries'), findsOneWidget); + await mouse.removePointer(); + expect( + tester.getRect(firstProgress).left - tester.getRect(find.byKey(const ValueKey('http-connection-status-0'))).right, + closeTo(8, 0.01), + ); + expect(tester.getRect(find.text('32 MB')).right, closeTo(tester.getRect(firstLane).right, 0.01)); + expect(tester.getRect(find.text('48 MB')).right, closeTo(tester.getRect(thirdLane).right, 0.01)); + expect(tester.takeException(), isNull); + }); + + testWidgets('HTTP connections use indeterminate progress only when total is unknown', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 320)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(16), + child: HttpConnectionLanes( + taskDownloading: true, + connections: [ + HttpConnectionStats(downloaded: 1024, total: 0, completed: false, failed: false, retryTimes: 0), + HttpConnectionStats(downloaded: 4096, total: 2048, completed: false, failed: false, retryTimes: 0), + ], + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + final unknown = tester.widget(find.byKey(const ValueKey('http-connection-progress-0'))); + final overflow = tester.widget(find.byKey(const ValueKey('http-connection-progress-1'))); + expect(unknown.indeterminate, isTrue); + expect(unknown.value, isNull); + expect(overflow.indeterminate, isFalse); + expect(overflow.value, 1); + }); + + testWidgets('HTTP connection progress stays static while its task is paused', (WidgetTester tester) async { + await _setTestSize(tester, const Size(390, 320)); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const Padding( + padding: EdgeInsets.all(16), + child: HttpConnectionLanes( + taskDownloading: false, + connections: [ + HttpConnectionStats(downloaded: 1024, total: 2048, completed: false, failed: false, retryTimes: 0), + HttpConnectionStats(downloaded: 1024, total: 0, completed: false, failed: false, retryTimes: 0), + ], + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + final known = tester.widget(find.byKey(const ValueKey('http-connection-progress-0'))); + final unknown = tester.widget(find.byKey(const ValueKey('http-connection-progress-1'))); + expect(known.shimmer, isFalse); + expect(known.indeterminate, isFalse); + expect(known.value, 0.5); + expect(unknown.shimmer, isFalse); + expect(unknown.indeterminate, isFalse); + expect(unknown.value, isNull); + }); + + testWidgets('task context menu exposes pause and selection operations', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 620)); + var paused = false; + var selected = false; + var detailsShown = false; + var directoryOpened = false; + var filesBrowsed = false; + final task = _taskRecord(id: 'context-task', name: 'context.zip'); + final actions = TaskContextActions( + selected: false, + allSelected: false, + listeningForUpdate: false, + onShowDetails: () => detailsShown = true, + onOpenFile: () {}, + onOpenDirectory: () => directoryOpened = true, + onBrowseFiles: () => filesBrowsed = true, + onToggleSelectAll: () {}, + onToggleSelected: () => selected = true, + onPause: () => paused = true, + onResume: () {}, + onDelete: () {}, + onUpdateUrl: () {}, + onToggleUpdateListener: () {}, + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: shad.Scaffold( + child: Center( + child: TaskContextMenu( + task: task, + actions: actions, + child: const SizedBox(key: ValueKey('context-target'), width: 260, height: 80), + ), + ), + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect(find.text('Details'), findsOneWidget); + expect(find.text('Open File'), findsNothing); + expect(find.text('Open Directory'), findsOneWidget); + expect(find.text('Browse Files'), findsOneWidget); + expect(find.text('Pause'), findsOneWidget); + expect(find.text('Select'), findsOneWidget); + expect(tester.getTopLeft(find.text('Details')).dy, greaterThan(tester.getTopLeft(find.text('Update URL')).dy)); + expect(tester.getTopLeft(find.text('Open Directory')).dy, greaterThan(tester.getTopLeft(find.text('Details')).dy)); + expect( + tester.getTopLeft(find.text('Browse Files')).dy, + greaterThan(tester.getTopLeft(find.text('Open Directory')).dy), + ); + + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect(detailsShown, isTrue); + + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Directory')); + await tester.pumpAndSettle(); + expect(directoryOpened, isTrue); + + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + await tester.tap(find.text('Browse Files')); + await tester.pumpAndSettle(); + expect(filesBrowsed, isTrue); + + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + await tester.tap(find.text('Select')); + await tester.pumpAndSettle(); + expect(selected, isTrue); + + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('context-target'))), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pause')); + await tester.pumpAndSettle(); + expect(paused, isTrue); + }); + + test('task context menu exposes system-open actions by platform and task type', () { + final completedFileTask = _taskRecord(id: 'completed-file-task', name: 'file.zip', status: TaskStatus.completed); + final downloadingFileTask = _taskRecord(id: 'downloading-file-task', name: 'file.zip'); + final completedDirectoryTask = _taskRecord( + id: 'completed-directory-task', + name: 'directory', + status: TaskStatus.completed, + isFolder: true, + ); + + expect(shouldShowTaskOpenFileAction(completedFileTask, web: false), isTrue); + expect(shouldShowTaskOpenFileAction(downloadingFileTask, web: false), isFalse); + expect(shouldShowTaskOpenFileAction(completedDirectoryTask, web: false), isFalse); + expect(shouldShowTaskOpenFileAction(completedFileTask, web: true), isFalse); + expect(shouldShowTaskOpenDirectoryAction(web: false, desktop: true), isTrue); + expect(shouldShowTaskOpenDirectoryAction(web: false, desktop: false), isFalse); + expect(shouldShowTaskOpenDirectoryAction(web: true, desktop: true), isFalse); + }); + + testWidgets('task URL update reuses the shared HTTP header editor', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + TaskUrlUpdate? update; + final task = _taskRecord( + id: 'update-url', + name: 'archive.zip', + status: TaskStatus.paused, + requestHeaders: const {'Authorization': 'Bearer old'}, + ); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) => shad.PrimaryButton( + onPressed: () async => update = await showTaskUpdateUrlDialog(context, task), + child: const Text('Open update dialog'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open update dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(AppHttpHeadersEditor), findsOneWidget); + final firstName = find.byKey(const ValueKey('update-task-http-header-name-0')); + final firstValue = find.byKey(const ValueKey('update-task-http-header-value-0')); + final firstNameField = find.descendant(of: firstName, matching: find.byType(shad.TextField)); + final firstValueField = find.descendant(of: firstValue, matching: find.byType(shad.TextField)); + expect(tester.widget(firstNameField).controller!.text, 'Authorization'); + expect(tester.widget(firstValueField).controller!.text, 'Bearer old'); + expect(tester.getSize(firstValue).width / tester.getSize(firstName).width, closeTo(1.618, 0.01)); + + await tester.tap(find.byKey(const ValueKey('update-task-http-header-add'))); + await tester.pump(); + final secondName = find.byKey(const ValueKey('update-task-http-header-name-1')); + final secondValue = find.byKey(const ValueKey('update-task-http-header-value-1')); + expect(secondName, findsOneWidget); + await tester.enterText(secondName, 'Referer'); + await tester.enterText(secondValue, 'https://example.com/source'); + + final addHeader = find.byKey(const ValueKey('update-task-http-header-add')); + final removeHeader = find.byKey(const ValueKey('update-task-http-header-remove-1')); + expect(tester.getCenter(addHeader).dy, closeTo(tester.getCenter(removeHeader).dy, 0.01)); + + await tester.tap(find.text('Update and resume')); + await tester.pumpAndSettle(); + expect(update?.headers, {'Authorization': 'Bearer old', 'Referer': 'https://example.com/source'}); + }); + + testWidgets('task URL update can be cancelled without a result', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + TaskUrlUpdate? update; + var completed = false; + final task = _taskRecord(id: 'cancel-update-url', name: 'archive.zip', status: TaskStatus.paused); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) => shad.PrimaryButton( + onPressed: () async { + update = await showTaskUpdateUrlDialog(context, task); + completed = true; + }, + child: const Text('Open update dialog'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open update dialog')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(completed, isTrue); + expect(update, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('delete dialog returns keep-files switch value', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 620)); + bool? keepFiles; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => shad.PrimaryButton( + onPressed: () async { + keepFiles = await showTaskDeleteDialog(context, taskCount: 2, keepFiles: true); + }, + child: const Text('Open delete dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Open delete dialog')); + await tester.pumpAndSettle(); + expect(find.text('Delete 2 tasks?'), findsOneWidget); + expect(find.text('Only remove the task record from Gopeed'), findsNothing); + expect(find.byKey(const ValueKey('task-delete-dialog-icon')), findsOneWidget); + expect(find.byKey(const ValueKey('task-delete-keep-files-option')), findsOneWidget); + final keepFilesSwitch = find.byKey(const ValueKey('task-delete-keep-files-switch')); + expect(tester.widget(keepFilesSwitch).value, isTrue); + + await tester.tap(keepFilesSwitch); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(keepFiles, isFalse); + }); + + testWidgets('pending URL update asks whether to update, create, or cancel', (WidgetTester tester) async { + await _setTestSize(tester, const Size(520, 620)); + PendingUpdateDecision? decision; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => shad.PrimaryButton( + onPressed: () async { + decision = await showPendingUpdateDialog(context, taskName: 'archive.zip'); + }, + child: const Text('Open pending update dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Open pending update dialog')); + await tester.pumpAndSettle(); + expect(find.text('Pending Update Task Found'), findsOneWidget); + expect( + find.text('Task "archive.zip" is waiting for URL update. Do you want to update it with the new URL?'), + findsOneWidget, + ); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Create New Task'), findsOneWidget); + expect(find.text('Update Task'), findsOneWidget); + + await tester.tap(find.text('Create New Task')); + await tester.pumpAndSettle(); + expect(decision, PendingUpdateDecision.createTask); + }); + + testWidgets('captured URL update preserves the full request and stops listening after confirmation', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1024, 768)); + late RecordingPendingUpdateTasksController tasksController; + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + appDeepLinkControllerProvider.overrideWith(FakeDeepLinkController.new), + appNotificationControllerProvider.overrideWith(FakeNotificationController.new), + tasksControllerProvider.overrideWith(() { + tasksController = RecordingPendingUpdateTasksController(); + return tasksController; + }), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(UncontrolledProviderScope(container: container, child: const GopeedApp())); + await tester.pumpAndSettle(); + + const listeningTask = PendingUpdateTask(id: 'paused-http', name: 'archive.zip'); + final replacementRequest = Request( + url: 'https://example.com/replacement.zip', + extra: const { + 'header': {'Authorization': 'Bearer replacement'}, + }, + skipVerifyCert: true, + ); + container.read(pendingUpdateTaskProvider.notifier).set(listeningTask); + container + .read(pendingUpdateRequestProvider.notifier) + .set( + PendingUpdateRequest( + task: listeningTask, + createTask: CreateTask(req: replacementRequest), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Pending Update Task Found'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(tasksController.updatedTaskId, isNull); + expect(container.read(pendingUpdateTaskProvider), same(listeningTask)); + expect(container.read(pendingUpdateRequestProvider), isNull); + + container + .read(pendingUpdateRequestProvider.notifier) + .set( + PendingUpdateRequest( + task: listeningTask, + createTask: CreateTask(req: replacementRequest), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Update Task')); + await tester.pumpAndSettle(); + + expect(tasksController.updatedTaskId, 'paused-http'); + expect(tasksController.updatedRequest, same(replacementRequest)); + expect(tasksController.updatedRequest?.skipVerifyCert, isTrue); + expect(tasksController.updatedRequest?.extra, replacementRequest.extra); + expect(container.read(pendingUpdateTaskProvider), isNull); + expect(container.read(pendingUpdateRequestProvider), isNull); + }); + + testWidgets('desktop batch pause sends only selected task ids', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + late RecordingTasksController tasksController; + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(() => tasksController = RecordingTasksController()), + ], + child: const GopeedApp(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.byIcon(Icons.checklist_rtl_outlined)); + await tester.pump(const Duration(milliseconds: 250)); + expect( + tester.widgetList(find.byType(shad.Checkbox)).every((item) => item.activeColor == null), + isTrue, + ); + await tester.tap(find.text('first.zip')); + await tester.pump(); + await tester.tap(find.byIcon(Icons.pause_outlined)); + await tester.pump(const Duration(milliseconds: 250)); + + expect(tasksController.pausedIds, ['first']); + }); + + testWidgets('completed task batch selection only enables delete', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(CompletedTasksController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Completed')); + await tester.pump(); + await tester.tap(find.byIcon(Icons.checklist_rtl_outlined)); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.text('done.zip')); + await tester.pump(); + + final actions = tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('tasks-top-action-buttons')), + matching: find.byType(shad.IconButton), + ), + ) + .toList(growable: false); + expect(actions, hasLength(4)); + expect(actions[0].onPressed, isNull); + expect(actions[1].onPressed, isNull); + expect(actions[2].onPressed, isNotNull); + expect(actions[3].onPressed, isNotNull); + }); + + testWidgets('batch selection rebuilds only the affected task', (WidgetTester tester) async { + final controller = TaskBatchSelectionController(); + addTearDown(controller.dispose); + const taskIds = ['first', 'second', 'third']; + final buildCounts = {for (final taskId in taskIds) taskId: 0}; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Row( + children: [ + for (final taskId in taskIds) + TaskBatchSelectionBuilder( + controller: controller, + taskId: taskId, + visibleTaskIds: taskIds, + builder: (context, selected, allSelected) { + buildCounts[taskId] = buildCounts[taskId]! + 1; + return Text('$taskId:$selected:$allSelected'); + }, + ), + ], + ), + ), + ); + buildCounts.updateAll((_, _) => 0); + + controller.toggle('first'); + await tester.pump(); + + expect(buildCounts, {'first': 1, 'second': 0, 'third': 0}); + expect(find.text('first:true:false'), findsOneWidget); + }); + + testWidgets('completed task selection responds immediately in batch mode', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + var selectedCount = 0; + var openedCount = 0; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'completed', name: 'completed.zip', status: TaskStatus.completed), + selected: false, + batchMode: true, + selectedInBatch: false, + onPressed: () => selectedCount++, + onToggleBatch: () => selectedCount++, + onOpen: () => openedCount++, + ), + ), + ), + ); + + await tester.tap(find.text('completed.zip')); + await tester.pump(); + + expect(selectedCount, 1); + expect(openedCount, 0); + }); + + testWidgets('completed task double click opens while folder action reveals', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + var openedCount = 0; + var revealedCount = 0; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'completed-actions', name: 'completed.zip', status: TaskStatus.completed), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + onOpen: () => openedCount++, + onReveal: () => revealedCount++, + ), + ), + ), + ); + + await tester.tap(find.text('completed.zip')); + await tester.pump(kDoubleTapMinTime); + await tester.tap(find.text('completed.zip')); + await tester.pumpAndSettle(); + + expect(openedCount, 1); + expect(revealedCount, 0); + + await tester.tap(find.byIcon(Icons.folder_open_outlined)); + await tester.pump(); + + expect(openedCount, 1); + expect(revealedCount, 1); + }); +} + +Future _setTestSize(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); +} + +class _ResponsiveMenuHarness extends StatefulWidget { + const _ResponsiveMenuHarness(); + + @override + State<_ResponsiveMenuHarness> createState() => _ResponsiveMenuHarnessState(); +} + +class _ResponsiveMenuHarnessState extends State<_ResponsiveMenuHarness> { + var _selected = 'general'; + var _showMobileContent = false; + + @override + Widget build(BuildContext context) { + return shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: ResponsiveMenuLayout( + title: 'Settings', + selectedValue: _selected, + onSelected: (value) => setState(() { + _selected = value; + _showMobileContent = true; + }), + items: const [ + ResponsiveMenuItem(value: 'general', label: 'General', icon: Icons.settings_outlined), + ResponsiveMenuItem(value: 'advanced', label: 'Advanced', icon: Icons.tune_outlined), + ], + mobileContentTitleBuilder: (value) => value == 'advanced' ? 'Advanced' : 'General', + mobileContentVisible: _showMobileContent, + onMobileBack: () => setState(() => _showMobileContent = false), + contentBuilder: (context, selected) { + return Text(selected == 'advanced' ? 'Advanced content' : 'General content'); + }, + ), + ); + } +} + +class _SettingsTestApp extends StatefulWidget { + const _SettingsTestApp({this.dark = false, this.wrapComponentThemes = false}); + + final bool dark; + final bool wrapComponentThemes; + + @override + State<_SettingsTestApp> createState() => _SettingsTestAppState(); +} + +class _SettingsTestAppState extends State<_SettingsTestApp> { + late final GoRouter _router; + + @override + void initState() { + super.initState(); + _router = GoRouter( + initialLocation: '/settings', + routes: [ + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + routes: [ + GoRoute( + path: ':section', + builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), + ), + ], + ), + ], + ); + } + + @override + void dispose() { + _router.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return shad.ShadcnApp.router( + theme: widget.dark ? AppTheme.dark() : AppTheme.light(), + materialTheme: widget.dark ? AppTheme.materialDark() : AppTheme.materialLight(), + builder: widget.wrapComponentThemes + ? (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()) + : null, + routerConfig: _router, + ); + } +} + +class _CreateTaskPageHarness extends StatelessWidget { + const _CreateTaskPageHarness(); + + @override + Widget build(BuildContext context) { + return shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const CreateTaskWindowPage(), + ); + } +} + +class FakeTasksController extends TasksController { + @override + Future build() async => const TasksState(tasks: []); +} + +class FailingTasksController extends TasksController { + @override + Future build() async => throw Exception('offline'); +} + +class FakeExtensionsController extends ExtensionsController { + int installCalls = 0; + int removeCalls = 0; + + @override + Future build() async { + final installed = api_extension.Extension( + identity: 'extension-0', + name: 'extension-0', + author: 'Gopeed', + title: 'Extension 0', + description: 'Installed extension', + icon: '', + version: '1.0.0', + homepage: '', + repository: null, + disabled: false, + devMode: false, + devPath: '', + ); + return ExtensionsState( + installedExtensions: [installed], + storeExtensions: List.generate( + 8, + (index) => StoreExtension( + id: 'extension-$index', + repoFullName: 'gopeed/extension-$index', + repoUrl: 'https://github.com/gopeed/extension-$index', + name: 'extension-$index', + author: 'Gopeed', + title: 'Extension $index', + description: 'Extension description $index', + readme: '# Extension $index README\n\nExtension details for testing.', + homepage: 'https://gopeed.com/extensions/extension-$index', + version: '1.0.0', + installCount: index, + stars: index, + topics: const ['download'], + ), + ), + ); + } + + @override + Future installFromStore(StoreExtension extension) async { + installCalls++; + } + + @override + Future toggleExtension(api_extension.Extension extension, bool enabled) async { + extension.disabled = !enabled; + state = AsyncValue.data( + state.requireValue.copyWith(installedExtensions: [...state.requireValue.installedExtensions]), + ); + } + + @override + Future removeExtension(api_extension.Extension extension) async { + removeCalls++; + } +} + +class DevReadmeExtensionsController extends FakeExtensionsController { + DevReadmeExtensionsController(this.extensionDirectory); + + final String extensionDirectory; + + @override + Future build() async { + final initial = await super.build(); + final installed = initial.installedExtensions.first + ..devMode = true + ..devPath = extensionDirectory; + return initial.copyWith(installedExtensions: [installed]); + } +} + +class LoadingExtensionsController extends ExtensionsController { + final _pending = Completer(); + + @override + Future build() => _pending.future; +} + +class LoadingMoreExtensionsController extends FakeExtensionsController { + @override + Future build() async { + final initial = await super.build(); + return initial.copyWith( + loadingMoreStore: true, + storePagination: StorePagination(page: 1, limit: 8, total: 16, totalPages: 2, hasNext: true, hasPrev: false), + ); + } +} + +class PaginatedExtensionsController extends FakeExtensionsController { + int loadMoreCalls = 0; + + @override + Future build() async { + final initial = await super.build(); + return initial.copyWith( + storePagination: StorePagination(page: 1, limit: 8, total: 16, totalPages: 2, hasNext: true, hasPrev: false), + ); + } + + @override + Future loadMoreStore() async { + final current = state.requireValue; + if (current.loadingMoreStore || current.storePagination?.hasNext != true) return; + loadMoreCalls++; + state = AsyncValue.data(current.copyWith(loadingMoreStore: true)); + await Future.delayed(const Duration(milliseconds: 20)); + state = AsyncValue.data( + state.requireValue.copyWith( + loadingMoreStore: false, + storePagination: StorePagination(page: 2, limit: 8, total: 16, totalPages: 2, hasNext: false, hasPrev: true), + ), + ); + } +} + +class BusyInstallExtensionsController extends FakeExtensionsController { + @override + Future build() async { + final initial = await super.build(); + return initial.copyWith(busyExtensionIds: {ExtensionsController.manualInstallBusyKey, 'extension-1'}); + } +} + +class FakeRuntimeController extends AppRuntimeController { + @override + Future build() async { + final cfg = StartConfig() + ..network = 'tcp' + ..address = '127.0.0.1:9999' + ..apiToken = '' + ..storage = 'bolt' + ..storageDir = '' + ..refreshInterval = 0; + return AppRuntimeState(startConfig: cfg, runningPort: 9999, downloaderConfig: DownloaderConfig()); + } +} + +class PendingTrackerRuntimeController extends FakeRuntimeController { + final _trackerUpdateCompleter = Completer(); + + @override + Future updateTrackers(DownloaderConfig config) => _trackerUpdateCompleter.future; + + void completeTrackerUpdate() => _trackerUpdateCompleter.complete(); +} + +class FailingTrackerRuntimeController extends FakeRuntimeController { + @override + Future updateTrackers(DownloaderConfig config) async { + throw StateError('tracker update failed'); + } +} + +class RecordingStartConfigRuntimeController extends AppRuntimeController { + StartConfig? savedStartConfig; + + @override + Future build() async { + final config = StartConfig() + ..network = 'tcp' + ..address = '192.168.1.20:4321' + ..apiToken = 'token' + ..storage = 'bolt' + ..storageDir = '' + ..refreshInterval = 0; + return AppRuntimeState(startConfig: config, runningPort: 4321, downloaderConfig: DownloaderConfig()); + } + + @override + Future saveStartConfig(StartConfig config) async { + savedStartConfig = StartConfig() + ..network = config.network + ..address = config.address + ..apiToken = config.apiToken + ..storage = config.storage + ..storageDir = config.storageDir + ..refreshInterval = config.refreshInterval; + } +} + +class FakeSettingsController extends SettingsController { + @override + Future build() async => SettingsState(config: DownloaderConfig()); + + @override + Future save(DownloaderConfig config) async { + state = AsyncValue.data(SettingsState(config: config)); + } +} + +class StaticSettingsController extends FakeSettingsController { + int reloadCalls = 0; + bool? lastReloadShowLoading; + + @override + Future reload({bool showLoading = true}) async { + reloadCalls++; + lastReloadShowLoading = showLoading; + final current = state.requireValue; + if (showLoading) state = const AsyncValue.loading(); + state = AsyncValue.data(current); + } +} + +class CategorySettingsController extends FakeSettingsController { + @override + Future build() async { + final config = DownloaderConfig(); + config.extra.downloadCategories = [DownloadCategory(name: '资料', path: '/tmp/data')]; + return SettingsState(config: config); + } +} + +class ProxySettingsController extends FakeSettingsController { + @override + Future build() async { + final config = DownloaderConfig(); + config.proxy + ..enable = true + ..system = false + ..scheme = 'socks5' + ..host = 'proxy.example.com:1080'; + return SettingsState(config: config); + } +} + +class Ed2kSettingsController extends FakeSettingsController { + @override + Future build() async { + final config = DownloaderConfig(); + config.protocolConfig.ed2k + ..serverAddr = 'server-a:4661,server-b:4661' + ..serverMet = 'https://example.com/server.met,https://mirror.example.com/server.met' + ..nodesDat = '/tmp/nodes.dat,https://example.com/nodes.dat'; + return SettingsState(config: config); + } +} + +class FakePlatformController extends AppPlatformController { + @override + Future build() async => const AppPlatformState(started: true); +} + +class FakeDeepLinkController extends AppDeepLinkController { + @override + Future build() async => const AppDeepLinkState(started: true); +} + +class FakeNotificationController extends AppNotificationController { + @override + Future build() async => const AppNotificationState(started: true); +} + +class RecordingPendingUpdateTasksController extends TasksController { + String? updatedTaskId; + Request? updatedRequest; + + @override + Future build() async => const TasksState(tasks: []); + + @override + Future updateRequest(String id, Request request) async { + updatedTaskId = id; + updatedRequest = request; + } +} + +class AvailableUpdatePlatformController extends AppPlatformController { + @override + Future build() async => const AppPlatformState( + started: true, + updateStatus: AppUpdateStatus.available, + latestVersion: VersionInfo( + version: '9.9.9', + changeLog: '# 更新日志\n\n- 测试更新', + releaseUrl: 'https://example.com/release', + ), + ); +} + +class CheckingUpdatePlatformController extends AppPlatformController { + @override + Future build() async => + const AppPlatformState(started: true, updateStatus: AppUpdateStatus.checking); +} + +class RecordingTasksController extends TasksController { + List? pausedIds; + + @override + Future build() async { + return TasksState( + tasks: [ + _taskRecord(id: 'first', name: 'first.zip'), + _taskRecord(id: 'second', name: 'second.zip'), + ], + ); + } + + @override + Future pauseAll(List? ids) async { + pausedIds = ids == null ? null : List.of(ids); + } +} + +class CompletedTasksController extends TasksController { + @override + Future build() async { + return TasksState( + tasks: [_taskRecord(id: 'done', name: 'done.zip', status: TaskStatus.completed)], + ); + } +} + +class RefreshingTaskFilesController extends TasksController { + @override + Future build() async { + return TasksState( + tasks: [_taskRecord(id: 'fresh', name: 'fresh.zip')], + ); + } + + void publishFiles() { + state = AsyncValue.data( + TasksState( + tasks: [ + _taskRecord( + id: 'fresh', + name: 'fresh.zip', + files: const [TaskFileNode(path: '', name: 'late-file.bin', sizeBytes: 128)], + ), + ], + ), + ); + } +} + +TaskRecord _taskRecord({ + required String id, + required String name, + TaskStatus status = TaskStatus.downloading, + List files = const [], + api_task.Protocol protocol = api_task.Protocol.http, + String? remaining, + Duration? downloadDuration, + DateTime? createdAt, + bool isFolder = false, + Map requestHeaders = const {}, +}) { + return TaskRecord( + id: id, + name: name, + status: status, + downloaded: '50 B', + total: '100 B', + speed: '10 B/s', + progress: 0.5, + url: 'https://example.com/$name', + storagePath: '/downloads/$name', + files: files, + uploading: false, + isFolder: isFolder, + protocol: protocol, + remaining: remaining, + downloadDuration: downloadDuration, + createdAt: createdAt, + requestHeaders: requestHeaders, + ); +} + +api_task.Task _apiTransferTask({ + required String id, + required api_task.Status status, + required bool uploading, + required int speed, + required int uploadSpeed, +}) { + return api_task.Task( + id: id, + name: id, + meta: Meta( + req: Request(url: 'https://example.com/$id'), + opts: Options(), + ), + status: status, + uploading: uploading, + progress: api_task.Progress(used: 0, speed: speed, downloaded: 0, uploadSpeed: uploadSpeed, uploaded: 0), + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + updatedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); } diff --git a/ui/flutter/web/favicon.png b/ui/flutter/web/favicon.png index f8b854de..8af3f70b 100644 Binary files a/ui/flutter/web/favicon.png and b/ui/flutter/web/favicon.png differ diff --git a/ui/flutter/web/icons/Icon-192.png b/ui/flutter/web/icons/Icon-192.png index b02a30c9..4ed0ace5 100644 Binary files a/ui/flutter/web/icons/Icon-192.png and b/ui/flutter/web/icons/Icon-192.png differ diff --git a/ui/flutter/web/icons/Icon-512.png b/ui/flutter/web/icons/Icon-512.png index 13b0d092..7dbd9f4e 100644 Binary files a/ui/flutter/web/icons/Icon-512.png and b/ui/flutter/web/icons/Icon-512.png differ diff --git a/ui/flutter/web/icons/Icon-maskable-192.png b/ui/flutter/web/icons/Icon-maskable-192.png index b02a30c9..4ed0ace5 100644 Binary files a/ui/flutter/web/icons/Icon-maskable-192.png and b/ui/flutter/web/icons/Icon-maskable-192.png differ diff --git a/ui/flutter/web/icons/Icon-maskable-512.png b/ui/flutter/web/icons/Icon-maskable-512.png index 13b0d092..7dbd9f4e 100644 Binary files a/ui/flutter/web/icons/Icon-maskable-512.png and b/ui/flutter/web/icons/Icon-maskable-512.png differ diff --git a/ui/flutter/web/index.html b/ui/flutter/web/index.html index 4a7ceaa9..c1ba6b53 100644 --- a/ui/flutter/web/index.html +++ b/ui/flutter/web/index.html @@ -18,6 +18,14 @@ - + + - \ No newline at end of file + diff --git a/ui/flutter/web/manifest.json b/ui/flutter/web/manifest.json index 778a6dfe..3e285875 100644 --- a/ui/flutter/web/manifest.json +++ b/ui/flutter/web/manifest.json @@ -3,9 +3,9 @@ "short_name": "gopeed", "start_url": ".", "display": "standalone", - "background_color": "#hexcode", - "theme_color": "#hexcode", - "description": "A new Flutter project.", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A modern download manager.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} \ No newline at end of file +} diff --git a/ui/flutter/windows/CMakeLists.txt b/ui/flutter/windows/CMakeLists.txt index 40a60cc0..60748efd 100644 --- a/ui/flutter/windows/CMakeLists.txt +++ b/ui/flutter/windows/CMakeLists.txt @@ -8,7 +8,7 @@ set(BINARY_NAME "gopeed") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. -cmake_policy(SET CMP0063 NEW) +cmake_policy(VERSION 3.14...3.25) # Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) @@ -52,6 +52,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -89,6 +90,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/ui/flutter/windows/runner/Runner.rc b/ui/flutter/windows/runner/Runner.rc index 6f414056..f9f40314 100644 --- a/ui/flutter/windows/runner/Runner.rc +++ b/ui/flutter/windows/runner/Runner.rc @@ -93,7 +93,7 @@ BEGIN VALUE "FileDescription", "gopeed" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "gopeed" "\0" - VALUE "LegalCopyright", "Copyright (C) 2023 com. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 GopeedLab. All rights reserved." "\0" VALUE "OriginalFilename", "gopeed.exe" "\0" VALUE "ProductName", "gopeed" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/ui/flutter/windows/runner/flutter_window.cpp b/ui/flutter/windows/runner/flutter_window.cpp index 1a7b26b0..5f4b6e27 100644 --- a/ui/flutter/windows/runner/flutter_window.cpp +++ b/ui/flutter/windows/runner/flutter_window.cpp @@ -27,8 +27,9 @@ bool FlutterWindow::OnCreate() { RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); - flutter_controller_->engine()->SetNextFrameCallback([&]() { - }); + // Dart's window_manager bootstrap decides whether the main window should be + // shown. This preserves --hidden startup for tray/autostart launches. + flutter_controller_->engine()->SetNextFrameCallback([&]() {}); // Flutter can complete the first frame before the "show window" callback is // registered. The following call ensures a frame is pending to ensure the diff --git a/ui/flutter/windows/runner/main.cpp b/ui/flutter/windows/runner/main.cpp index 657a0070..c8c65677 100644 --- a/ui/flutter/windows/runner/main.cpp +++ b/ui/flutter/windows/runner/main.cpp @@ -2,57 +2,47 @@ #include #include -#include "flutter_window.h" -#include "utils.h" +#include + #include "app_links/app_links_plugin_c_api.h" +#include "flutter_window.h" +#include "flutter/generated_plugin_registrant.h" +#include "utils.h" bool SendAppLinkToInstance(const std::wstring& title) { - // Find our exact window HWND hwnd = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", title.c_str()); - - if (hwnd) { - // Dispatch new link to current window - SendAppLink(hwnd); - - // (Optional) Restore our window to front in same state - WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) }; - GetWindowPlacement(hwnd, &place); - - switch(place.showCmd) { - case SW_SHOWMAXIMIZED: - ShowWindow(hwnd, SW_SHOWMAXIMIZED); - break; - case SW_SHOWMINIMIZED: - ShowWindow(hwnd, SW_RESTORE); - break; - default: - ShowWindow(hwnd, SW_NORMAL); - break; - } - - SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); - SetForegroundWindow(hwnd); - // END (Optional) Restore - - // Window has been found, don't create another one. - return true; + if (!hwnd) { + return false; } - return false; + SendAppLink(hwnd); + + WINDOWPLACEMENT place = {sizeof(WINDOWPLACEMENT)}; + GetWindowPlacement(hwnd, &place); + switch (place.showCmd) { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, + SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + return true; +} + +bool IsMultiWindowLaunch(const std::vector& arguments) { + return !arguments.empty() && arguments.front() == "multi_window"; } int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { - if (SendAppLinkToInstance(L"gopeed")) { - return EXIT_SUCCESS; - } - - HWND hwnd = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", L"gopeed"); - if (hwnd != NULL) { - ::ShowWindow(hwnd, SW_NORMAL); - ::SetForegroundWindow(hwnd); - return EXIT_FAILURE; - } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { @@ -64,9 +54,17 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); + std::vector command_line_arguments = GetCommandLineArguments(); + if (!IsMultiWindowLaunch(command_line_arguments) && + SendAppLinkToInstance(L"gopeed")) { + return EXIT_SUCCESS; + } - std::vector command_line_arguments = - GetCommandLineArguments(); + DesktopMultiWindowSetWindowCreatedCallback([](void *controller) { + auto *flutter_view_controller = + reinterpret_cast(controller); + RegisterPlugins(flutter_view_controller->engine()); + }); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); diff --git a/ui/flutter/windows/runner/resources/app_icon.ico b/ui/flutter/windows/runner/resources/app_icon.ico index 1996c5d4..33f7b379 100644 Binary files a/ui/flutter/windows/runner/resources/app_icon.ico and b/ui/flutter/windows/runner/resources/app_icon.ico differ diff --git a/ui/flutter/windows/runner/runner.exe.manifest b/ui/flutter/windows/runner/runner.exe.manifest index a42ea768..153653e8 100644 --- a/ui/flutter/windows/runner/runner.exe.manifest +++ b/ui/flutter/windows/runner/runner.exe.manifest @@ -9,12 +9,6 @@ - - - - - - diff --git a/ui/flutter/windows/runner/utils.cpp b/ui/flutter/windows/runner/utils.cpp index f5bf9fa0..3a0b4651 100644 --- a/ui/flutter/windows/runner/utils.cpp +++ b/ui/flutter/windows/runner/utils.cpp @@ -45,9 +45,11 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } - int target_length = ::WideCharToMultiByte( + unsigned int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr); + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); std::string utf8_string; if (target_length == 0 || target_length > utf8_string.max_size()) { return utf8_string; @@ -55,8 +57,7 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, utf8_string.data(), - target_length, nullptr, nullptr); + input_length, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } diff --git a/ui/flutter/windows/runner/win32_window.cpp b/ui/flutter/windows/runner/win32_window.cpp index 041a3855..60608d0f 100644 --- a/ui/flutter/windows/runner/win32_window.cpp +++ b/ui/flutter/windows/runner/win32_window.cpp @@ -60,7 +60,7 @@ class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; - // Returns the singleton registar instance. + // Returns the singleton registrar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); diff --git a/ui/flutter/windows/runner/win32_window.h b/ui/flutter/windows/runner/win32_window.h index c86632d8..e901dde6 100644 --- a/ui/flutter/windows/runner/win32_window.h +++ b/ui/flutter/windows/runner/win32_window.h @@ -77,7 +77,7 @@ class Win32Window { // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically - // responsponds to changes in DPI. All other messages are handled by + // responds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, diff --git a/ui/flutter_old/.gitignore b/ui/flutter_old/.gitignore new file mode 100644 index 00000000..9e9c2f15 --- /dev/null +++ b/ui/flutter_old/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +.fvm/ + +# Web related + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +# Libgopeed +windows/libgopeed.h +windows/libgopeed.dll +macos/Frameworks/libgopeed.h +macos/Frameworks/libgopeed.dylib +linux/bundle/lib/libgopeed.h +linux/bundle/lib/libgopeed.dylib +android/app/libs/libgopeed.aar +android/app/libs/libgopeed-sources.jar + +debian/packages + +linux/flutter/generated_plugin_registrant.cc +linux/flutter/generated_plugin_registrant.h +linux/flutter/generated_plugins.cmake +macos/Flutter/GeneratedPluginRegistrant.swift +windows/flutter/generated_plugin_registrant.cc +windows/flutter/generated_plugin_registrant.h +windows/flutter/generated_plugins.cmake + +/extensions + +# Hive database files +database.hive +database.lock + +assets/exec/host +assets/exec/host.exe +assets/exec/updater +assets/exec/updater.exe diff --git a/ui/flutter_old/.metadata b/ui/flutter_old/.metadata new file mode 100644 index 00000000..33f93bc1 --- /dev/null +++ b/ui/flutter_old/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: windows + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ui/flutter_old/analysis_options.yaml b/ui/flutter_old/analysis_options.yaml new file mode 100644 index 00000000..61b6c4de --- /dev/null +++ b/ui/flutter_old/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/ui/flutter_old/android/.gitignore b/ui/flutter_old/android/.gitignore new file mode 100644 index 00000000..6f568019 --- /dev/null +++ b/ui/flutter_old/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/ui/flutter/android/app/build.gradle b/ui/flutter_old/android/app/build.gradle similarity index 100% rename from ui/flutter/android/app/build.gradle rename to ui/flutter_old/android/app/build.gradle diff --git a/ui/flutter_old/android/app/libs/.gitkeep b/ui/flutter_old/android/app/libs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ui/flutter_old/android/app/src/debug/AndroidManifest.xml b/ui/flutter_old/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..c4a4b76b --- /dev/null +++ b/ui/flutter_old/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/ui/flutter_old/android/app/src/main/AndroidManifest.xml b/ui/flutter_old/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..56b8329c --- /dev/null +++ b/ui/flutter_old/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt b/ui/flutter_old/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt similarity index 100% rename from ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt rename to ui/flutter_old/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt diff --git a/ui/flutter_old/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt b/ui/flutter_old/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt new file mode 100644 index 00000000..d054f1da --- /dev/null +++ b/ui/flutter_old/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt @@ -0,0 +1,49 @@ +package com.gopeed.gopeed + +import androidx.annotation.NonNull +import com.gopeed.libgopeed.Libgopeed +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMethodCodec + +open class MainActivity : FlutterActivity() { + private val CHANNEL = "gopeed.com/libgopeed" + + protected open fun isDialogMode(): Boolean = false + + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + val taskQueue = + flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue() + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + CHANNEL, + StandardMethodCodec.INSTANCE, + taskQueue + ).setMethodCallHandler { call, result -> + when (call.method) { + "start" -> { + val cfg = call.argument("cfg") + try { + val port = Libgopeed.start(cfg) + result.success(port) + } catch (e: Exception) { + result.error("ERROR", e.message, null) + } + } + "stop" -> { + Libgopeed.stop() + result.success(null) + } + "isDialogMode" -> { + result.success(isDialogMode()) + } + else -> { + result.notImplemented() + } + } + } + } + +} diff --git a/ui/flutter_old/android/app/src/main/res/drawable-v21/launch_background.xml b/ui/flutter_old/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/ui/flutter_old/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ui/flutter_old/android/app/src/main/res/drawable/launch_background.xml b/ui/flutter_old/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/ui/flutter_old/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/ui/flutter_old/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/flutter_old/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..7671b90a Binary files /dev/null and b/ui/flutter_old/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ui/flutter_old/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/flutter_old/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..33a417fc Binary files /dev/null and b/ui/flutter_old/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ui/flutter_old/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/flutter_old/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..1a3b18ed Binary files /dev/null and b/ui/flutter_old/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ui/flutter_old/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/flutter_old/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d1e9e867 Binary files /dev/null and b/ui/flutter_old/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ui/flutter_old/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/flutter_old/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b02a30c9 Binary files /dev/null and b/ui/flutter_old/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ui/flutter_old/android/app/src/main/res/values-night/styles.xml b/ui/flutter_old/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..7c601a8b --- /dev/null +++ b/ui/flutter_old/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/ui/flutter_old/android/app/src/main/res/values/styles.xml b/ui/flutter_old/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..36c4b265 --- /dev/null +++ b/ui/flutter_old/android/app/src/main/res/values/styles.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/ui/flutter_old/android/app/src/profile/AndroidManifest.xml b/ui/flutter_old/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..c4a4b76b --- /dev/null +++ b/ui/flutter_old/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/ui/flutter/android/build.gradle b/ui/flutter_old/android/build.gradle similarity index 100% rename from ui/flutter/android/build.gradle rename to ui/flutter_old/android/build.gradle diff --git a/ui/flutter_old/android/gradle.properties b/ui/flutter_old/android/gradle.properties new file mode 100644 index 00000000..5b3d6e64 --- /dev/null +++ b/ui/flutter_old/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +android.nonTransitiveRClass=true +android.nonFinalResIds=true +android.defaults.buildfeatures.buildconfig=true diff --git a/ui/flutter_old/android/gradle/wrapper/gradle-wrapper.properties b/ui/flutter_old/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9162f100 --- /dev/null +++ b/ui/flutter_old/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/ui/flutter/android/settings.gradle b/ui/flutter_old/android/settings.gradle similarity index 100% rename from ui/flutter/android/settings.gradle rename to ui/flutter_old/android/settings.gradle diff --git a/ui/flutter_old/assets/exec/.gitkeep b/ui/flutter_old/assets/exec/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ui/flutter_old/assets/extension/default_icon.png b/ui/flutter_old/assets/extension/default_icon.png new file mode 100644 index 00000000..d8b2dbc3 Binary files /dev/null and b/ui/flutter_old/assets/extension/default_icon.png differ diff --git a/ui/flutter/assets/fonts/Gopeed.ttf b/ui/flutter_old/assets/fonts/Gopeed.ttf similarity index 100% rename from ui/flutter/assets/fonts/Gopeed.ttf rename to ui/flutter_old/assets/fonts/Gopeed.ttf diff --git a/ui/flutter_old/assets/icon/icon.ico b/ui/flutter_old/assets/icon/icon.ico new file mode 100644 index 00000000..87757168 Binary files /dev/null and b/ui/flutter_old/assets/icon/icon.ico differ diff --git a/ui/flutter_old/assets/icon/icon.svg b/ui/flutter_old/assets/icon/icon.svg new file mode 100644 index 00000000..5d7a2a02 --- /dev/null +++ b/ui/flutter_old/assets/icon/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/flutter_old/assets/icon/icon_1024.png b/ui/flutter_old/assets/icon/icon_1024.png new file mode 100644 index 00000000..cb5383e7 Binary files /dev/null and b/ui/flutter_old/assets/icon/icon_1024.png differ diff --git a/ui/flutter_old/assets/icon/icon_512.png b/ui/flutter_old/assets/icon/icon_512.png new file mode 100644 index 00000000..99b1055b Binary files /dev/null and b/ui/flutter_old/assets/icon/icon_512.png differ diff --git a/ui/flutter_old/assets/icon/icon_macos_1024.png b/ui/flutter_old/assets/icon/icon_macos_1024.png new file mode 100644 index 00000000..05b0a8f4 Binary files /dev/null and b/ui/flutter_old/assets/icon/icon_macos_1024.png differ diff --git a/ui/flutter_old/assets/tray_icon/icon.ico b/ui/flutter_old/assets/tray_icon/icon.ico new file mode 100644 index 00000000..87757168 Binary files /dev/null and b/ui/flutter_old/assets/tray_icon/icon.ico differ diff --git a/ui/flutter_old/assets/tray_icon/icon.png b/ui/flutter_old/assets/tray_icon/icon.png new file mode 100644 index 00000000..10974b1f Binary files /dev/null and b/ui/flutter_old/assets/tray_icon/icon.png differ diff --git a/ui/flutter_old/assets/tray_icon/icon_mac.png b/ui/flutter_old/assets/tray_icon/icon_mac.png new file mode 100644 index 00000000..83980e19 Binary files /dev/null and b/ui/flutter_old/assets/tray_icon/icon_mac.png differ diff --git a/ui/flutter_old/build.yaml b/ui/flutter_old/build.yaml new file mode 100644 index 00000000..9e73a339 --- /dev/null +++ b/ui/flutter_old/build.yaml @@ -0,0 +1,19 @@ +targets: + $default: + builders: + json_serializable: + options: + # Options configure how source code is generated for every + # `@JsonSerializable`-annotated class in the package. + # + # The default value for each is listed. + any_map: false + checked: false + create_factory: true + create_to_json: true + disallow_unrecognized_keys: false + explicit_to_json: false + field_rename: none + generic_argument_factories: false + ignore_unannotated: false + include_if_null: false \ No newline at end of file diff --git a/ui/flutter_old/distribute_options.yaml b/ui/flutter_old/distribute_options.yaml new file mode 100644 index 00000000..57792289 --- /dev/null +++ b/ui/flutter_old/distribute_options.yaml @@ -0,0 +1 @@ +output: dist/ \ No newline at end of file diff --git a/ui/flutter_old/include/libgopeed.h b/ui/flutter_old/include/libgopeed.h new file mode 100644 index 00000000..766cf7e6 --- /dev/null +++ b/ui/flutter_old/include/libgopeed.h @@ -0,0 +1,88 @@ +/* Code generated by cmd/cgo; DO NOT EDIT. */ + +/* package github.com/GopeedLab/gopeed/bind/desktop */ + + +#line 1 "cgo-builtin-export-prolog" + +#include + +#ifndef GO_CGO_EXPORT_PROLOGUE_H +#define GO_CGO_EXPORT_PROLOGUE_H + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef struct { const char *p; ptrdiff_t n; } _GoString_; +#endif + +#endif + +/* Start of preamble from import "C" comments. */ + + + + +/* End of preamble from import "C" comments. */ + + +/* Start of boilerplate cgo prologue. */ +#line 1 "cgo-gcc-export-header-prolog" + +#ifndef GO_CGO_PROLOGUE_H +#define GO_CGO_PROLOGUE_H + +typedef signed char GoInt8; +typedef unsigned char GoUint8; +typedef short GoInt16; +typedef unsigned short GoUint16; +typedef int GoInt32; +typedef unsigned int GoUint32; +typedef long long GoInt64; +typedef unsigned long long GoUint64; +typedef GoInt64 GoInt; +typedef GoUint64 GoUint; +typedef size_t GoUintptr; +typedef float GoFloat32; +typedef double GoFloat64; +#ifdef _MSC_VER +#include +typedef _Fcomplex GoComplex64; +typedef _Dcomplex GoComplex128; +#else +typedef float _Complex GoComplex64; +typedef double _Complex GoComplex128; +#endif + +/* + static assertion to make sure the file is being used on architecture + at least with matching size of GoInt. +*/ +typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef _GoString_ GoString; +#endif +typedef void *GoMap; +typedef void *GoChan; +typedef struct { void *t; void *v; } GoInterface; +typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; + +#endif + +/* End of boilerplate cgo prologue. */ + +#ifdef __cplusplus +extern "C" { +#endif + + +/* Return type for Start */ +struct Start_return { + GoInt r0; + char* r1; +}; +extern struct Start_return Start(char* cfg); +extern void Stop(); + +#ifdef __cplusplus +} +#endif diff --git a/ui/flutter_old/ios/.gitignore b/ui/flutter_old/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/ui/flutter_old/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ui/flutter_old/ios/Flutter/AppFrameworkInfo.plist b/ui/flutter_old/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..9625e105 --- /dev/null +++ b/ui/flutter_old/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/ui/flutter_old/ios/Flutter/Debug.xcconfig b/ui/flutter_old/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/ui/flutter_old/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ui/flutter_old/ios/Flutter/Release.xcconfig b/ui/flutter_old/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/ui/flutter_old/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ui/flutter_old/ios/Podfile b/ui/flutter_old/ios/Podfile new file mode 100644 index 00000000..2f4162c7 --- /dev/null +++ b/ui/flutter_old/ios/Podfile @@ -0,0 +1,48 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # share_handler addition start + target 'ShareExtension' do + inherit! :search_paths + pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models" + end + # share_handler addition end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ui/flutter_old/ios/Podfile.lock b/ui/flutter_old/ios/Podfile.lock new file mode 100644 index 00000000..2b31cd1a --- /dev/null +++ b/ui/flutter_old/ios/Podfile.lock @@ -0,0 +1,52 @@ +PODS: + - file_selector_ios (0.0.1): + - Flutter + - Flutter (1.0.0) + - package_info_plus (0.4.5): + - Flutter + - path_provider_ios (0.0.1): + - Flutter + - permission_handler_apple (9.0.4): + - Flutter + - shared_preferences_ios (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) + - Flutter (from `Flutter`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" + Flutter: + :path: Flutter + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_ios: + :path: ".symlinks/plugins/path_provider_ios/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + file_selector_ios: 6e9b6d7300556c730f224905726f0a2d5d874e51 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e + path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.11.3 diff --git a/ui/flutter_old/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter_old/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..1dc4cb6b --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,812 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */; }; + 0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; }; + 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */; }; + D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D081C25B294826C0006EB10B /* libc++.tbd */; }; + D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; }; + D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */; }; + A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LocationKeepAliveManager.swift */;}; + A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference;lastKnownFileType = sourcecode.swift;path = LocationKeepAliveManager.swift;sourceTree = "";}; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0C585CB62D41E28900FF2EC0; + remoteInfo = ShareExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 8; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 1; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = ""; }; + 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + 0C585CBE2D41E28900FF2EC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + D081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; + D0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0C585CB42D41E28900FF2EC0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */, + D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */, + DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */, + B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0C585CB82D41E28900FF2EC0 /* ShareExtension */ = { + isa = PBXGroup; + children = ( + 0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */, + 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */, + 0C585CBB2D41E28900FF2EC0 /* MainInterface.storyboard */, + 0C585CBE2D41E28900FF2EC0 /* Info.plist */, + ); + path = ShareExtension; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 0C585CB82D41E28900FF2EC0 /* ShareExtension */, + 97C146EF1CF9000F007C117D /* Products */, + ADF7F433B4BC23D6C18AB78B /* Pods */, + B4A1CBAC5AA50208793CFF34 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A10000000000000000000002 /* LocationKeepAliveManager.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + ADF7F433B4BC23D6C18AB78B /* Pods */ = { + isa = PBXGroup; + children = ( + 97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */, + BC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */, + 25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + B4A1CBAC5AA50208793CFF34 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D0E623A729482D030001185E /* Libgopeed.xcframework */, + D081C25B294826C0006EB10B /* libc++.tbd */, + 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */, + 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0C585CB62D41E28900FF2EC0 /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 0C585CB32D41E28900FF2EC0 /* Sources */, + 0C585CB42D41E28900FF2EC0 /* Frameworks */, + 0C585CB52D41E28900FF2EC0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareExtension; + productName = ShareExtension; + productReference = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + F3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + DDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 0C585CB62D41E28900FF2EC0 = { + CreatedOnToolsVersion = 15.4; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 0C585CB62D41E28900FF2EC0 /* ShareExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0C585CB52D41E28900FF2EC0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C585CBD2D41E28900FF2EC0 /* Base in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + DDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0C585CB32D41E28900FF2EC0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0C585CB62D41E28900FF2EC0 /* ShareExtension */; + targetProxy = 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 0C585CBB2D41E28900FF2EC0 /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0C585CBC2D41E28900FF2EC0 /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0C585CC32D41E28900FF2EC0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH48DS925K; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0C585CC42D41E28900FF2EC0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH48DS925K; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 0C585CC52D41E28900FF2EC0 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JH48DS925K; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = JH48DS925K; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = JH48DS925K; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = JH48DS925K; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0C585CC32D41E28900FF2EC0 /* Debug */, + 0C585CC42D41E28900FF2EC0 /* Release */, + 0C585CC52D41E28900FF2EC0 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ui/flutter_old/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ui/flutter_old/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..a6b826db --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/flutter_old/ios/Runner.xcworkspace/contents.xcworkspacedata b/ui/flutter_old/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ui/flutter_old/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ui/flutter_old/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ui/flutter_old/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ui/flutter_old/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ui/flutter_old/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ui/flutter_old/ios/Runner/AppDelegate.swift b/ui/flutter_old/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..cf13bafa --- /dev/null +++ b/ui/flutter_old/ios/Runner/AppDelegate.swift @@ -0,0 +1,75 @@ +import UIKit +import Flutter +import Libgopeed + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController + let batteryChannel = FlutterMethodChannel(name: "gopeed.com/libgopeed", + binaryMessenger: controller.binaryMessenger) + batteryChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "start": + let args = call.arguments as? Dictionary + let cfg = args?["cfg"] as? String + let portPrt = UnsafeMutablePointer.allocate(capacity: MemoryLayout.stride) + var error: NSError? + if LibgopeedStart(cfg, portPrt, &error){ + result(portPrt.pointee) + }else{ + result(FlutterError(code: "ERROR", message: error.debugDescription, details: nil)) + } + case "stop": + LibgopeedStop() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + }) + + let locationChannel = FlutterMethodChannel( + name: "gopeed/location_keep_alive", + binaryMessenger: controller.binaryMessenger +) + + locationChannel.setMethodCallHandler { + (call: FlutterMethodCall, result: @escaping FlutterResult) in + + switch call.method { + + case "requestPermission": + LocationKeepAliveManager.shared.requestPermission { authorized in + result(authorized) + } + + case "start": + LocationKeepAliveManager.shared.start() + result(nil) + + case "stop": + LocationKeepAliveManager.shared.stop() + result(nil) + + default: + result(FlutterMethodNotImplemented) + } +} + + GeneratedPluginRegistrant.register(with: self) + + SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback(registerPlugins) + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +func registerPlugins(registry: FlutterPluginRegistry) { + GeneratedPluginRegistrant.register(with: registry) +} diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..ebb599e9 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..bd992b24 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..8000e850 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..4388230a Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..86b1cfd2 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..9d5dacad Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..d7ad8951 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..8000e850 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..327d490d Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..2e3603dd Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..3a680a90 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..d4be832e Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..f3fb629b Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..e63dad43 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..2e3603dd Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..a7bbecde Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..a5666c9f Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..5da20a9d Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..4a2479aa Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..adb453f2 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..931b4c25 Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/ui/flutter_old/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ui/flutter_old/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ui/flutter_old/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/ui/flutter_old/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/flutter_old/ios/Runner/Base.lproj/Main.storyboard b/ui/flutter_old/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/ui/flutter_old/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/flutter_old/ios/Runner/Info.plist b/ui/flutter_old/ios/Runner/Info.plist new file mode 100644 index 00000000..6e7c20c7 --- /dev/null +++ b/ui/flutter_old/ios/Runner/Info.plist @@ -0,0 +1,120 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gopeed + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UIApplicationSupportsIndirectInputEvents + + + + + NSUserActivityTypes + + INSendMessageIntent + + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + + + NSPhotoLibraryUsageDescription + Photos can be shared to and used in this app + + + LSSupportsOpeningDocumentsInPlace + No + CFBundleDocumentTypes + + + CFBundleTypeName + ShareHandler + LSHandlerRank + Alternate + LSItemContentTypes + + public.file-url + public.image + public.text + public.movie + public.url + public.data + + + + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UIBackgroundModes + + location + + NSLocationWhenInUseUsageDescription + Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. + NSLocationAlwaysAndWhenInUseUsageDescription + Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. + NSLocationAlwaysUsageDescription + Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. + + \ No newline at end of file diff --git a/ui/flutter_old/ios/Runner/LocationKeepAliveManager.swift b/ui/flutter_old/ios/Runner/LocationKeepAliveManager.swift new file mode 100644 index 00000000..33f2b047 --- /dev/null +++ b/ui/flutter_old/ios/Runner/LocationKeepAliveManager.swift @@ -0,0 +1,126 @@ +import Foundation +import CoreLocation + +final class LocationKeepAliveManager: NSObject, CLLocationManagerDelegate { + + static let shared = LocationKeepAliveManager() + + private let locationManager = CLLocationManager() + + private(set) var isRunning = false + + private var permissionCompletion: ((Bool) -> Void)? + + override init() { + super.init() + + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers + locationManager.distanceFilter = kCLDistanceFilterNone + locationManager.pausesLocationUpdatesAutomatically = false + + if #available(iOS 9.0, *) { + locationManager.allowsBackgroundLocationUpdates = true + } + + if #available(iOS 14.0, *) { + locationManager.showsBackgroundLocationIndicator = true + } + } + + func requestPermission(completion: @escaping (Bool) -> Void) { + let status = CLLocationManager.authorizationStatus() + + switch status { + case .authorizedAlways: + completion(true) + + case .authorizedWhenInUse: + completion(false) + + case .denied, .restricted: + completion(false) + + case .notDetermined: + permissionCompletion = completion + locationManager.requestAlwaysAuthorization() + + @unknown default: + completion(false) + } + } + + + func start() { + guard !isRunning else { + return + } + + let status = CLLocationManager.authorizationStatus() + + guard status == .authorizedAlways else { + return + } + + if #available(iOS 9.0, *) { + locationManager.allowsBackgroundLocationUpdates = true + } + + locationManager.startUpdatingLocation() + isRunning = true + } + + func stop() { + locationManager.stopUpdatingLocation() + isRunning = false + } + + private func handleAuthorizationStatus( + _ status: CLAuthorizationStatus + ) { + switch status { + case .authorizedAlways: + permissionCompletion?(true) + permissionCompletion = nil + + case .authorizedWhenInUse, + .denied, + .restricted: + permissionCompletion?(false) + permissionCompletion = nil + + case .notDetermined: + break + + @unknown default: + permissionCompletion?(false) + permissionCompletion = nil + } + } + + @available(iOS 14.0, *) + func locationManagerDidChangeAuthorization( + _ manager: CLLocationManager + ) { + handleAuthorizationStatus(manager.authorizationStatus) + } + + func locationManager( + _ manager: CLLocationManager, + didChangeAuthorization status: CLAuthorizationStatus + ) { + handleAuthorizationStatus(status) + } + + func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + } + + func locationManager( + _ manager: CLLocationManager, + didFailWithError error: Error + ) { + } +} \ No newline at end of file diff --git a/ui/flutter_old/ios/Runner/Runner-Bridging-Header.h b/ui/flutter_old/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..10e9f025 --- /dev/null +++ b/ui/flutter_old/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1,2 @@ +#import "GeneratedPluginRegistrant.h" +#import diff --git a/ui/flutter_old/ios/Runner/Runner.entitlements b/ui/flutter_old/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..f8bb8d49 --- /dev/null +++ b/ui/flutter_old/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.gopeed.gopeed + + + diff --git a/ui/flutter_old/ios/ShareExtension/Base.lproj/MainInterface.storyboard b/ui/flutter_old/ios/ShareExtension/Base.lproj/MainInterface.storyboard new file mode 100644 index 00000000..286a5089 --- /dev/null +++ b/ui/flutter_old/ios/ShareExtension/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/flutter_old/ios/ShareExtension/Info.plist b/ui/flutter_old/ios/ShareExtension/Info.plist new file mode 100644 index 00000000..f21d8454 --- /dev/null +++ b/ui/flutter_old/ios/ShareExtension/Info.plist @@ -0,0 +1,49 @@ + + + + + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionAttributes + + + IntentsSupported + + INSendMessageIntent + + + NSExtensionActivationRule + + + + SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, + $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY + $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY + $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY + $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count + > 0 + PHSupportedMediaTypes + + Video + Image + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + \ No newline at end of file diff --git a/ui/flutter_old/ios/ShareExtension/ShareExtension.entitlements b/ui/flutter_old/ios/ShareExtension/ShareExtension.entitlements new file mode 100644 index 00000000..f8bb8d49 --- /dev/null +++ b/ui/flutter_old/ios/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.gopeed.gopeed + + + diff --git a/ui/flutter_old/ios/ShareExtension/ShareViewController.swift b/ui/flutter_old/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000..b1b38efc --- /dev/null +++ b/ui/flutter_old/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,3 @@ +import share_handler_ios_models + +class ShareViewController: ShareHandlerIosViewController {} \ No newline at end of file diff --git a/ui/flutter_old/lib/api/api.dart b/ui/flutter_old/lib/api/api.dart new file mode 100644 index 00000000..80f95147 --- /dev/null +++ b/ui/flutter_old/lib/api/api.dart @@ -0,0 +1,310 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart' as getx; + +import '../app/routes/app_pages.dart'; +import '../database/database.dart'; +import '../util/util.dart'; +import 'model/create_task.dart'; +import 'model/create_task_batch.dart'; +import 'model/downloader_config.dart'; +import 'model/extension.dart'; +import 'model/install_extension.dart'; +import 'model/login.dart'; +import 'model/resolve_result.dart'; +import 'model/resolve_task.dart'; +import 'model/result.dart'; +import 'model/switch_extension.dart'; +import 'model/task.dart'; +import 'model/update_check_extension_resp.dart'; +import 'model/update_extension_settings.dart'; + +class _Client { + static _Client? _instance; + + late Dio dio; + + _Client._internal(); + + factory _Client(String network, String address, String apiToken) { + if (_instance == null) { + _instance = _Client._internal(); + var dio = Dio(); + final isUnixSocket = network == 'unix'; + var baseUrl = 'http://127.0.0.1/'; + if (!isUnixSocket) { + if (Util.isWeb()) { + baseUrl = kDebugMode ? 'http://127.0.0.1:9999/' : ''; + } else { + baseUrl = 'http://$address/'; + } + } + dio.options.baseUrl = baseUrl; + dio.options.contentType = Headers.jsonContentType; + dio.options.sendTimeout = const Duration(seconds: 5); + dio.options.connectTimeout = const Duration(seconds: 5); + dio.options.receiveTimeout = const Duration(seconds: 120); + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + if (apiToken.isNotEmpty) { + options.headers['X-Api-Token'] = apiToken; + } + if (Util.isWeb()) { + final token = Database.instance.getWebToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } + handler.next(options); + }, + onError: (error, handler) { + // Only web version has a login page + if (Util.isWeb() && error.response?.statusCode == 401) { + getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN); + } + handler.next(error); + }, + )); + + _instance!.dio = dio; + if (isUnixSocket) { + (_instance!.dio.httpClientAdapter as IOHttpClientAdapter) + .createHttpClient = () { + final client = HttpClient(); + client.connectionFactory = + (Uri uri, String? proxyHost, int? proxyPort) { + return Socket.startConnect( + InternetAddress(address, type: InternetAddressType.unix), 0); + }; + return client; + }; + } + } + return _instance!; + } +} + +class TimeoutException implements Exception { + final String message; + + TimeoutException(this.message); +} + +late _Client _client; + +void init(String network, String address, String apiToken) { + _client = _Client(network, address, apiToken); +} + +/// Used by LocationKeepAliveCoordinator to reconcile keep-alive +void Function()? _onTaskChanged; + +void setTaskChangedListener(void Function() listener) { + _onTaskChanged = listener; +} + +void _notifyTaskChanged() { + _onTaskChanged?.call(); +} + +Future _parse( + Future Function() fetch, + T Function(dynamic json)? fromJsonT, +) async { + try { + var resp = await fetch(); + fromJsonT ??= (json) => null as T; + final result = Result.fromJson(resp.data, fromJsonT); + if (result.code == 0) { + return result.data as T; + } else { + throw Exception(result); + } + } on DioException catch (e) { + if (e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { + throw TimeoutException("request timeout"); + } + throw Exception(Result(code: 1000, msg: e.message)); + } +} + +Future resolve(ResolveTask resolveTask) async { + return _parse( + () => _client.dio.post("api/v1/resolve", data: resolveTask), + (data) => ResolveResult.fromJson(data)); +} + +Future createTask(CreateTask createTask) async { + final result = await _parse( + () => _client.dio.post("api/v1/tasks", data: createTask), + (data) => data as String); + _notifyTaskChanged(); + return result; +} + +Future> createTaskBatch(CreateTaskBatch createTaskBatch) async { + final result = await _parse>( + () => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch), + (data) => (data as List).map((e) => e as String).toList()); + _notifyTaskChanged(); + return result; +} + +Future patchTask(String id, ResolveTask patchTask) async { + return _parse( + () => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null); +} + +Future> getTasks(List statuses) async { + return _parse>( + () => _client.dio.get( + "/api/v1/tasks?${statuses.map((e) => "status=${e.name}").join("&")}"), + (data) => (data as List).map((e) => Task.fromJson(e)).toList()); +} + +Future pauseTask(String id) async { + await _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null); + _notifyTaskChanged(); +} + +Future continueTask(String id) async { + await _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null); + _notifyTaskChanged(); +} + +Future pauseAllTasks(List? ids) async { + await _parse( + () => _client.dio.put("api/v1/tasks/pause", queryParameters: { + "id": ids, + }), + null); + _notifyTaskChanged(); +} + +Future continueAllTasks(List? ids) async { + await _parse( + () => _client.dio.put("api/v1/tasks/continue", queryParameters: { + "id": ids, + }), + null); + _notifyTaskChanged(); +} + +Future deleteTask(String id, bool force) async { + await _parse( + () => _client.dio.delete("api/v1/tasks/$id?force=$force"), null); + _notifyTaskChanged(); +} + +Future deleteTasks(List? ids, bool force) async { + await _parse( + () => _client.dio.delete("api/v1/tasks", queryParameters: { + "id": ids, + "force": force, + }), + null); + _notifyTaskChanged(); +} + +Future getConfig() async { + return _parse(() => _client.dio.get("api/v1/config"), + (data) => DownloaderConfig.fromJson(data)); +} + +Future putConfig(DownloaderConfig config) async { + return _parse(() => _client.dio.put("api/v1/config", data: config), null); +} + +Future installExtension(InstallExtension installExtension) async { + return _parse( + () => _client.dio.post("api/v1/extensions", data: installExtension), + (data) => data as String); +} + +Future> getExtensions() async { + return _parse>(() => _client.dio.get("api/v1/extensions"), + (data) => (data as List).map((e) => Extension.fromJson(e)).toList()); +} + +Future updateExtensionSettings( + String identity, UpdateExtensionSettings updateExtensionSettings) async { + return _parse( + () => _client.dio.put("api/v1/extensions/$identity/settings", + data: updateExtensionSettings), + null); +} + +Future switchExtension( + String identity, SwitchExtension switchExtension) async { + return _parse( + () => _client.dio + .put("api/v1/extensions/$identity/switch", data: switchExtension), + null); +} + +Future deleteExtension(String identity) async { + return _parse(() => _client.dio.delete("api/v1/extensions/$identity"), null); +} + +Future upgradeCheckExtension(String identity) async { + return _parse(() => _client.dio.get("api/v1/extensions/$identity/update"), + (data) => UpdateCheckExtensionResp.fromJson(data)); +} + +Future updateExtension(String identity) async { + return _parse( + () => _client.dio.post("api/v1/extensions/$identity/update"), null); +} + +Future testWebhook(String url) async { + return _parse( + () => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null); +} + +Future login(LoginReq loginReq) async { + return _parse(() => _client.dio.post("api/web/login", data: loginReq), + (data) => data as String); +} + +Future> proxyRequest(String uri, + {data, Options? options}) async { + options ??= Options(); + options.headers ??= {}; + options.headers!["X-Target-Uri"] = uri; + + // add timestamp to avoid cache + return _client.dio.request( + "/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}", + data: data, + options: options); +} + +String join(String path) { + final baseUrl = _client.dio.options.baseUrl; + final cleanBaseUrl = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + return "$cleanBaseUrl/${Util.cleanPath(path)}"; +} + +/// Generic request method for API proxy +/// Directly forwards requests to gopeed REST API +Future forward( + String path, { + String method = 'GET', + dynamic data, + Map? queryParameters, +}) async { + return _client.dio.request( + path, + data: data, + queryParameters: queryParameters, + options: Options(method: method), + ); +} diff --git a/ui/flutter_old/lib/api/gopeed_site_api.dart b/ui/flutter_old/lib/api/gopeed_site_api.dart new file mode 100644 index 00000000..4e4e2e30 --- /dev/null +++ b/ui/flutter_old/lib/api/gopeed_site_api.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import 'api.dart'; +import 'model/store_extension.dart'; + +class GopeedSiteApi { + GopeedSiteApi._(); + + static final instance = GopeedSiteApi._(); + + static const _host = 'gopeed.com'; + + Future> getRelease() async { + final json = await _getJson('/api/release'); + return json as Map; + } + + Future getExtensions({ + int page = 1, + int limit = 20, + StoreExtensionSort sort = StoreExtensionSort.stars, + StoreSortOrder order = StoreSortOrder.desc, + String? query, + }) async { + final json = await _getJson('/api/extensions', queryParameters: { + 'page': page.toString(), + 'limit': limit.clamp(1, 100).toString(), + 'sort': sort.name, + 'order': order.name, + if (query != null && query.trim().isNotEmpty) 'q': query.trim(), + }); + return StoreExtensionPage.fromJson(json as Map); + } + + Future reportExtensionInstall(String id) async { + final uri = Uri.https(_host, '/api/extensions/install'); + await proxyRequest( + uri.toString(), + data: {'id': id}, + options: Options(method: 'POST', contentType: Headers.jsonContentType), + ); + } + + Future _getJson(String path, + {Map? queryParameters}) async { + final uri = Uri.https(_host, path, queryParameters); + final Response response = await proxyRequest(uri.toString()); + if (response.data == null || response.data!.isEmpty) { + throw Exception('Empty response from $uri'); + } + return jsonDecode(response.data!); + } +} diff --git a/ui/flutter_old/lib/api/model/create_task.dart b/ui/flutter_old/lib/api/model/create_task.dart new file mode 100644 index 00000000..5ddbe999 --- /dev/null +++ b/ui/flutter_old/lib/api/model/create_task.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'options.dart'; +import 'request.dart'; + +part 'create_task.g.dart'; + +@JsonSerializable(explicitToJson: true) +class CreateTask { + String? rid; + Request? req; + Options? opts; + + CreateTask({ + this.rid, + this.req, + this.opts, + }); + + factory CreateTask.fromJson( + Map json, + ) => + _$CreateTaskFromJson(json); + + Map toJson() => _$CreateTaskToJson(this); +} diff --git a/ui/flutter_old/lib/api/model/create_task.g.dart b/ui/flutter_old/lib/api/model/create_task.g.dart new file mode 100644 index 00000000..f36e9432 --- /dev/null +++ b/ui/flutter_old/lib/api/model/create_task.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_task.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateTask _$CreateTaskFromJson(Map json) => CreateTask( + rid: json['rid'] as String?, + req: json['req'] == null + ? null + : Request.fromJson(json['req'] as Map), + opts: json['opts'] == null + ? null + : Options.fromJson(json['opts'] as Map), + ); + +Map _$CreateTaskToJson(CreateTask instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('rid', instance.rid); + writeNotNull('req', instance.req?.toJson()); + writeNotNull('opts', instance.opts?.toJson()); + return val; +} diff --git a/ui/flutter_old/lib/api/model/create_task_batch.dart b/ui/flutter_old/lib/api/model/create_task_batch.dart new file mode 100644 index 00000000..470c5465 --- /dev/null +++ b/ui/flutter_old/lib/api/model/create_task_batch.dart @@ -0,0 +1,39 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'options.dart'; +import 'request.dart'; + +part 'create_task_batch.g.dart'; + +@JsonSerializable(explicitToJson: true) +class CreateTaskBatch { + List? reqs; + Options? opts; + + CreateTaskBatch({ + this.reqs, + this.opts, + }); + + factory CreateTaskBatch.fromJson( + Map json, + ) => + _$CreateTaskBatchFromJson(json); + + Map toJson() => _$CreateTaskBatchToJson(this); +} + +@JsonSerializable() +class CreateTaskBatchItem { + Request? req; + Options? opts; + + CreateTaskBatchItem({ + this.req, + this.opts, + }); + + factory CreateTaskBatchItem.fromJson(Map json) => + _$CreateTaskBatchItemFromJson(json); + Map toJson() => _$CreateTaskBatchItemToJson(this); +} diff --git a/ui/flutter_old/lib/api/model/create_task_batch.g.dart b/ui/flutter_old/lib/api/model/create_task_batch.g.dart new file mode 100644 index 00000000..cf01d8fb --- /dev/null +++ b/ui/flutter_old/lib/api/model/create_task_batch.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_task_batch.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => + CreateTaskBatch( + reqs: (json['reqs'] as List?) + ?.map((e) => CreateTaskBatchItem.fromJson(e as Map)) + .toList(), + opts: json['opts'] == null + ? null + : Options.fromJson(json['opts'] as Map), + ); + +Map _$CreateTaskBatchToJson(CreateTaskBatch instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('reqs', instance.reqs?.map((e) => e.toJson()).toList()); + writeNotNull('opts', instance.opts?.toJson()); + return val; +} + +CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map json) => + CreateTaskBatchItem( + req: json['req'] == null + ? null + : Request.fromJson(json['req'] as Map), + opts: json['opts'] == null + ? null + : Options.fromJson(json['opts'] as Map), + ); + +Map _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('req', instance.req?.toJson()); + writeNotNull('opts', instance.opts?.toJson()); + return val; +} diff --git a/ui/flutter_old/lib/api/model/downloader_config.dart b/ui/flutter_old/lib/api/model/downloader_config.dart new file mode 100644 index 00000000..e179b3c5 --- /dev/null +++ b/ui/flutter_old/lib/api/model/downloader_config.dart @@ -0,0 +1,310 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'downloader_config.g.dart'; + +@JsonSerializable(explicitToJson: true) +class DownloaderConfig { + String downloadDir; + int maxRunning; + ProtocolConfig protocolConfig = ProtocolConfig(); + ExtraConfig extra = ExtraConfig(); + ProxyConfig proxy = ProxyConfig(); + WebhookConfig webhook = WebhookConfig(); + ScriptConfig script = ScriptConfig(); + AutoTorrentConfig autoTorrent = AutoTorrentConfig(); + ArchiveConfig archive = ArchiveConfig(); + bool autoStartTasks; + bool autoDeleteMissingFileTasks; + + DownloaderConfig({ + this.downloadDir = '', + this.maxRunning = 0, + this.autoStartTasks = false, + this.autoDeleteMissingFileTasks = false, + }); + + factory DownloaderConfig.fromJson(Map json) => + _$DownloaderConfigFromJson(json); + + Map toJson() => _$DownloaderConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ProtocolConfig { + HttpConfig http = HttpConfig(); + BtConfig bt = BtConfig(); + Ed2kConfig ed2k = Ed2kConfig(); + + ProtocolConfig(); + + factory ProtocolConfig.fromJson(Map? json) => + json == null ? ProtocolConfig() : _$ProtocolConfigFromJson(json); + + Map toJson() => _$ProtocolConfigToJson(this); +} + +@JsonSerializable() +class HttpConfig { + String userAgent; + int connections; + bool useServerCtime; + + HttpConfig({ + this.userAgent = '', + this.connections = 0, + this.useServerCtime = false, + }); + + factory HttpConfig.fromJson(Map json) => + _$HttpConfigFromJson(json); + + Map toJson() => _$HttpConfigToJson(this); +} + +@JsonSerializable() +class BtConfig { + int listenPort; + List trackers; + bool seedKeep; + double seedRatio; + int seedTime; + + BtConfig({ + this.listenPort = 0, + this.trackers = const [], + this.seedKeep = false, + this.seedRatio = 0, + this.seedTime = 0, + }); + + factory BtConfig.fromJson(Map json) => + _$BtConfigFromJson(json); + + Map toJson() => _$BtConfigToJson(this); +} + +@JsonSerializable() +class Ed2kConfig { + int listenPort; + int udpPort; + String serverAddr; + String serverMet; + String nodesDat; + + Ed2kConfig({ + this.listenPort = 0, + this.udpPort = 0, + this.serverAddr = '', + this.serverMet = '', + this.nodesDat = '', + }); + + factory Ed2kConfig.fromJson(Map json) => + _$Ed2kConfigFromJson(json); + + Map toJson() => _$Ed2kConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ExtraConfig { + String themeMode; + String locale; + bool lastDeleteTaskKeep; + bool defaultDirectDownload; + bool defaultBtClient; + bool notifyWhenNewVersion; + bool desktopNotification; + bool backgroundLocationKeepAlive; + List downloadCategories; + + ExtraConfigBt bt = ExtraConfigBt(); + ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror(); + + ExtraConfig({ + this.themeMode = '', + this.locale = '', + this.lastDeleteTaskKeep = false, + this.defaultDirectDownload = false, + this.defaultBtClient = true, + this.notifyWhenNewVersion = true, + this.desktopNotification = true, + this.backgroundLocationKeepAlive = false, + this.downloadCategories = const [], + }); + + factory ExtraConfig.fromJson(Map? json) => + json == null ? ExtraConfig() : _$ExtraConfigFromJson(json); + + Map toJson() => _$ExtraConfigToJson(this); +} + +@JsonSerializable() +class DownloadCategory { + String name; + String path; + bool isBuiltIn; + String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic') + bool + isDeleted; // Mark built-in categories as deleted instead of removing them + + DownloadCategory({ + required this.name, + required this.path, + this.isBuiltIn = false, + this.nameKey, + this.isDeleted = false, + }); + + factory DownloadCategory.fromJson(Map json) => + _$DownloadCategoryFromJson(json); + + Map toJson() => _$DownloadCategoryToJson(this); +} + +@JsonSerializable() +class WebhookConfig { + bool enable; + List urls; + + WebhookConfig({ + this.enable = false, + this.urls = const [], + }); + + factory WebhookConfig.fromJson(Map? json) => + json == null ? WebhookConfig() : _$WebhookConfigFromJson(json); + + Map toJson() => _$WebhookConfigToJson(this); +} + +@JsonSerializable() +class ScriptConfig { + bool enable; + List paths; + + ScriptConfig({ + this.enable = false, + this.paths = const [], + }); + + factory ScriptConfig.fromJson(Map? json) => + json == null ? ScriptConfig() : _$ScriptConfigFromJson(json); + + Map toJson() => _$ScriptConfigToJson(this); +} + +@JsonSerializable() +class ProxyConfig { + bool enable; + bool system; + String scheme; + String host; + String usr; + String pwd; + + ProxyConfig({ + this.enable = false, + this.system = false, + this.scheme = '', + this.host = '', + this.usr = '', + this.pwd = '', + }); + + factory ProxyConfig.fromJson(Map json) => + _$ProxyConfigFromJson(json); + + Map toJson() => _$ProxyConfigToJson(this); +} + +@JsonSerializable() +class ExtraConfigBt { + List trackerSubscribeUrls = []; + List subscribeTrackers = []; + bool autoUpdateTrackers = true; + DateTime? lastTrackerUpdateTime; + + List customTrackers = []; + + ExtraConfigBt(); + + factory ExtraConfigBt.fromJson(Map json) => + _$ExtraConfigBtFromJson(json); + + Map toJson() => _$ExtraConfigBtToJson(this); +} + +enum GithubMirrorType { + jsdelivr, + ghProxy, +} + +@JsonSerializable() +class GithubMirror { + GithubMirrorType type; + String url; + bool isBuiltIn; + bool isDeleted; + + GithubMirror({ + required this.type, + required this.url, + this.isBuiltIn = false, + this.isDeleted = false, + }); + + factory GithubMirror.fromJson(Map json) => + _$GithubMirrorFromJson(json); + + Map toJson() => _$GithubMirrorToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ExtraConfigGithubMirror { + bool enabled; + List mirrors; + + ExtraConfigGithubMirror({ + this.enabled = true, + this.mirrors = const [], + }); + + factory ExtraConfigGithubMirror.fromJson(Map? json) => + json == null + ? ExtraConfigGithubMirror() + : _$ExtraConfigGithubMirrorFromJson(json); + + Map toJson() => _$ExtraConfigGithubMirrorToJson(this); +} + +@JsonSerializable() +class AutoTorrentConfig { + bool enable; + bool deleteAfterDownload; + + AutoTorrentConfig({ + this.enable = false, + this.deleteAfterDownload = false, + }); + + factory AutoTorrentConfig.fromJson(Map? json) => + json == null ? AutoTorrentConfig() : _$AutoTorrentConfigFromJson(json); + + Map toJson() => _$AutoTorrentConfigToJson(this); +} + +@JsonSerializable() +class ArchiveConfig { + bool autoExtract; + bool deleteAfterExtract; + + ArchiveConfig({ + this.autoExtract = true, + this.deleteAfterExtract = true, + }); + + factory ArchiveConfig.fromJson(Map? json) => + json == null ? ArchiveConfig() : _$ArchiveConfigFromJson(json); + + Map toJson() => _$ArchiveConfigToJson(this); +} diff --git a/ui/flutter_old/lib/api/model/downloader_config.g.dart b/ui/flutter_old/lib/api/model/downloader_config.g.dart new file mode 100644 index 00000000..54cb9396 --- /dev/null +++ b/ui/flutter_old/lib/api/model/downloader_config.g.dart @@ -0,0 +1,309 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'downloader_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DownloaderConfig _$DownloaderConfigFromJson(Map json) => + DownloaderConfig( + downloadDir: json['downloadDir'] as String? ?? '', + maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0, + autoStartTasks: json['autoStartTasks'] as bool? ?? false, + autoDeleteMissingFileTasks: + json['autoDeleteMissingFileTasks'] as bool? ?? false, + ) + ..protocolConfig = ProtocolConfig.fromJson( + json['protocolConfig'] as Map?) + ..extra = ExtraConfig.fromJson(json['extra'] as Map?) + ..proxy = ProxyConfig.fromJson(json['proxy'] as Map) + ..webhook = + WebhookConfig.fromJson(json['webhook'] as Map?) + ..script = ScriptConfig.fromJson(json['script'] as Map?) + ..autoTorrent = AutoTorrentConfig.fromJson( + json['autoTorrent'] as Map?) + ..archive = + ArchiveConfig.fromJson(json['archive'] as Map?); + +Map _$DownloaderConfigToJson(DownloaderConfig instance) => + { + 'downloadDir': instance.downloadDir, + 'maxRunning': instance.maxRunning, + 'protocolConfig': instance.protocolConfig.toJson(), + 'extra': instance.extra.toJson(), + 'proxy': instance.proxy.toJson(), + 'webhook': instance.webhook.toJson(), + 'script': instance.script.toJson(), + 'autoTorrent': instance.autoTorrent.toJson(), + 'archive': instance.archive.toJson(), + 'autoStartTasks': instance.autoStartTasks, + 'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks, + }; + +ProtocolConfig _$ProtocolConfigFromJson(Map json) => + ProtocolConfig() + ..http = HttpConfig.fromJson(json['http'] as Map) + ..bt = BtConfig.fromJson(json['bt'] as Map) + ..ed2k = Ed2kConfig.fromJson( + json['ed2k'] as Map? ?? {}); + +Map _$ProtocolConfigToJson(ProtocolConfig instance) => + { + 'http': instance.http.toJson(), + 'bt': instance.bt.toJson(), + 'ed2k': instance.ed2k.toJson(), + }; + +HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( + userAgent: json['userAgent'] as String? ?? '', + connections: (json['connections'] as num?)?.toInt() ?? 0, + useServerCtime: json['useServerCtime'] as bool? ?? false, + ); + +Map _$HttpConfigToJson(HttpConfig instance) => + { + 'userAgent': instance.userAgent, + 'connections': instance.connections, + 'useServerCtime': instance.useServerCtime, + }; + +BtConfig _$BtConfigFromJson(Map json) => BtConfig( + listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, + trackers: (json['trackers'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + seedKeep: json['seedKeep'] as bool? ?? false, + seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, + seedTime: (json['seedTime'] as num?)?.toInt() ?? 0, + ); + +Map _$BtConfigToJson(BtConfig instance) => { + 'listenPort': instance.listenPort, + 'trackers': instance.trackers, + 'seedKeep': instance.seedKeep, + 'seedRatio': instance.seedRatio, + 'seedTime': instance.seedTime, + }; + +Ed2kConfig _$Ed2kConfigFromJson(Map json) => Ed2kConfig( + listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, + udpPort: (json['udpPort'] as num?)?.toInt() ?? 0, + serverAddr: json['serverAddr'] as String? ?? '', + serverMet: json['serverMet'] as String? ?? '', + nodesDat: json['nodesDat'] as String? ?? '', + ); + +Map _$Ed2kConfigToJson(Ed2kConfig instance) => + { + 'listenPort': instance.listenPort, + 'udpPort': instance.udpPort, + 'serverAddr': instance.serverAddr, + 'serverMet': instance.serverMet, + 'nodesDat': instance.nodesDat, + }; + +ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( + themeMode: json['themeMode'] as String? ?? '', + locale: json['locale'] as String? ?? '', + lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false, + defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false, + defaultBtClient: json['defaultBtClient'] as bool? ?? true, + notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, + desktopNotification: json['desktopNotification'] as bool? ?? true, + backgroundLocationKeepAlive: + json['backgroundLocationKeepAlive'] as bool? ?? false, + downloadCategories: (json['downloadCategories'] as List?) + ?.map((e) => DownloadCategory.fromJson(e as Map)) + .toList() ?? + const [], + ) + ..bt = ExtraConfigBt.fromJson(json['bt'] as Map) + ..githubMirror = ExtraConfigGithubMirror.fromJson( + json['githubMirror'] as Map?); + +Map _$ExtraConfigToJson(ExtraConfig instance) => + { + 'themeMode': instance.themeMode, + 'locale': instance.locale, + 'lastDeleteTaskKeep': instance.lastDeleteTaskKeep, + 'defaultDirectDownload': instance.defaultDirectDownload, + 'defaultBtClient': instance.defaultBtClient, + 'notifyWhenNewVersion': instance.notifyWhenNewVersion, + 'desktopNotification': instance.desktopNotification, + 'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive, + 'downloadCategories': + instance.downloadCategories.map((e) => e.toJson()).toList(), + 'bt': instance.bt.toJson(), + 'githubMirror': instance.githubMirror.toJson(), + }; + +DownloadCategory _$DownloadCategoryFromJson(Map json) => + DownloadCategory( + name: json['name'] as String, + path: json['path'] as String, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + nameKey: json['nameKey'] as String?, + isDeleted: json['isDeleted'] as bool? ?? false, + ); + +Map _$DownloadCategoryToJson(DownloadCategory instance) { + final val = { + 'name': instance.name, + 'path': instance.path, + 'isBuiltIn': instance.isBuiltIn, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('nameKey', instance.nameKey); + val['isDeleted'] = instance.isDeleted; + return val; +} + +WebhookConfig _$WebhookConfigFromJson(Map json) => + WebhookConfig( + enable: json['enable'] as bool? ?? false, + urls: + (json['urls'] as List?)?.map((e) => e as String).toList() ?? + const [], + ); + +Map _$WebhookConfigToJson(WebhookConfig instance) => + { + 'enable': instance.enable, + 'urls': instance.urls, + }; + +ScriptConfig _$ScriptConfigFromJson(Map json) => ScriptConfig( + enable: json['enable'] as bool? ?? false, + paths: + (json['paths'] as List?)?.map((e) => e as String).toList() ?? + const [], + ); + +Map _$ScriptConfigToJson(ScriptConfig instance) => + { + 'enable': instance.enable, + 'paths': instance.paths, + }; + +ProxyConfig _$ProxyConfigFromJson(Map json) => ProxyConfig( + enable: json['enable'] as bool? ?? false, + system: json['system'] as bool? ?? false, + scheme: json['scheme'] as String? ?? '', + host: json['host'] as String? ?? '', + usr: json['usr'] as String? ?? '', + pwd: json['pwd'] as String? ?? '', + ); + +Map _$ProxyConfigToJson(ProxyConfig instance) => + { + 'enable': instance.enable, + 'system': instance.system, + 'scheme': instance.scheme, + 'host': instance.host, + 'usr': instance.usr, + 'pwd': instance.pwd, + }; + +ExtraConfigBt _$ExtraConfigBtFromJson(Map json) => + ExtraConfigBt() + ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List) + .map((e) => e as String) + .toList() + ..subscribeTrackers = (json['subscribeTrackers'] as List) + .map((e) => e as String) + .toList() + ..autoUpdateTrackers = json['autoUpdateTrackers'] as bool + ..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null + ? null + : DateTime.parse(json['lastTrackerUpdateTime'] as String) + ..customTrackers = (json['customTrackers'] as List) + .map((e) => e as String) + .toList(); + +Map _$ExtraConfigBtToJson(ExtraConfigBt instance) { + final val = { + 'trackerSubscribeUrls': instance.trackerSubscribeUrls, + 'subscribeTrackers': instance.subscribeTrackers, + 'autoUpdateTrackers': instance.autoUpdateTrackers, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('lastTrackerUpdateTime', + instance.lastTrackerUpdateTime?.toIso8601String()); + val['customTrackers'] = instance.customTrackers; + return val; +} + +GithubMirror _$GithubMirrorFromJson(Map json) => GithubMirror( + type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']), + url: json['url'] as String, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + isDeleted: json['isDeleted'] as bool? ?? false, + ); + +Map _$GithubMirrorToJson(GithubMirror instance) => + { + 'type': _$GithubMirrorTypeEnumMap[instance.type]!, + 'url': instance.url, + 'isBuiltIn': instance.isBuiltIn, + 'isDeleted': instance.isDeleted, + }; + +const _$GithubMirrorTypeEnumMap = { + GithubMirrorType.jsdelivr: 'jsdelivr', + GithubMirrorType.ghProxy: 'ghProxy', +}; + +ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson( + Map json) => + ExtraConfigGithubMirror( + enabled: json['enabled'] as bool? ?? true, + mirrors: (json['mirrors'] as List?) + ?.map((e) => GithubMirror.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$ExtraConfigGithubMirrorToJson( + ExtraConfigGithubMirror instance) => + { + 'enabled': instance.enabled, + 'mirrors': instance.mirrors.map((e) => e.toJson()).toList(), + }; + +AutoTorrentConfig _$AutoTorrentConfigFromJson(Map json) => + AutoTorrentConfig( + enable: json['enable'] as bool? ?? false, + deleteAfterDownload: json['deleteAfterDownload'] as bool? ?? false, + ); + +Map _$AutoTorrentConfigToJson(AutoTorrentConfig instance) => + { + 'enable': instance.enable, + 'deleteAfterDownload': instance.deleteAfterDownload, + }; + +ArchiveConfig _$ArchiveConfigFromJson(Map json) => + ArchiveConfig( + autoExtract: json['autoExtract'] as bool? ?? true, + deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? true, + ); + +Map _$ArchiveConfigToJson(ArchiveConfig instance) => + { + 'autoExtract': instance.autoExtract, + 'deleteAfterExtract': instance.deleteAfterExtract, + }; diff --git a/ui/flutter_old/lib/api/model/extension.dart b/ui/flutter_old/lib/api/model/extension.dart new file mode 100644 index 00000000..3bd3196e --- /dev/null +++ b/ui/flutter_old/lib/api/model/extension.dart @@ -0,0 +1,97 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'extension.g.dart'; + +@JsonSerializable(explicitToJson: true) +class Extension { + String identity; + String name; + String author; + String title; + String description; + String icon; + String version; + String homepage; + Repository? repository; + List? settings; + bool disabled; + bool devMode; + String devPath; + + Extension({ + required this.identity, + required this.name, + required this.author, + required this.title, + required this.description, + required this.icon, + required this.version, + required this.homepage, + required this.repository, + required this.disabled, + required this.devMode, + required this.devPath, + }); + + factory Extension.fromJson(Map json) => + _$ExtensionFromJson(json); + Map toJson() => _$ExtensionToJson(this); +} + +@JsonSerializable() +class Repository { + String url; + String directory; + + Repository({ + required this.url, + required this.directory, + }); + + factory Repository.fromJson(Map json) => + _$RepositoryFromJson(json); + Map toJson() => _$RepositoryToJson(this); +} + +@JsonSerializable() +class Setting { + String name; + String title; + String description; + bool required; + SettingType type; + Object? value; + List