From 59458484aa382dc0b75226246067023ae62943d3 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Thu, 1 Jan 2026 23:08:22 +0000 Subject: [PATCH] add build step for flutter web, add persistent pagination in the traction list --- .gitea/workflows/release.yml | 105 ++++++++++++++++++ Dockerfile.web | 10 ++ deploy/web/nginx.conf | 28 +++++ lib/components/legs/leg_card.dart | 6 - .../pages/new_entry/new_entry_page.dart | 42 +------ .../new_entry/new_entry_submit_logic.dart | 1 + lib/components/pages/new_traction.dart | 94 ++++++++++++++++ .../pages/traction/traction_page.dart | 66 ++++++++++- .../pages/traction/traction_persistence.dart | 12 ++ .../data_service/data_service_traction.dart | 2 +- lib/services/distance_unit_service.dart | 2 +- web/index.html | 7 +- web/manifest.json | 9 +- 13 files changed, 326 insertions(+), 58 deletions(-) create mode 100644 Dockerfile.web create mode 100644 deploy/web/nginx.conf diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9ce9080..923d6c3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -12,6 +12,7 @@ env: FLUTTER_VERSION: "3.38.5" BUILD_WINDOWS: "false" # Windows build disabled (no runner available) GITEA_BASE_URL: https://git.tgj.services + WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web" jobs: meta: @@ -255,6 +256,108 @@ jobs: name: linux-bundle path: app-linux-x64.tar.gz + web-build: + runs-on: + - mileograph + needs: meta + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install OS deps (Web) + run: | + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + SUDO="" + fi + $SUDO apt-get update + $SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq docker.io + if ! docker info >/dev/null 2>&1; then + $SUDO systemctl start docker 2>/dev/null || $SUDO service docker start 2>/dev/null || true + fi + + - name: Install Flutter SDK + run: | + set -euo pipefail + FLUTTER_HOME="$HOME/flutter" + git config --global --add safe.directory "$FLUTTER_HOME" || true + if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then + rm -rf "$FLUTTER_HOME" + curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" + tar -C "$HOME" -xf /tmp/flutter.tar.xz + fi + echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH" + "$FLUTTER_HOME/bin/flutter" --version + + - name: Allow all git directories (CI) + run: git config --global --add safe.directory '*' + + - name: Set pub cache path + run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV" + + - name: Flutter dependencies + run: flutter pub get + + - name: Enable Flutter web + run: flutter config --enable-web + + - name: Build Flutter web (release) + run: | + flutter build web --release --base-href=/ + tar -C build/web -czf app-web.tar.gz . + + - name: Upload Web artifact + uses: actions/upload-artifact@v3 + with: + name: web-build + path: app-web.tar.gz + + - name: Compute web image tags + id: web_meta + env: + BASE_VERSION: ${{ needs.meta.outputs.base_version }} + DEV_SUFFIX: ${{ needs.meta.outputs.dev_suffix }} + run: | + IMAGE="${WEB_IMAGE}" + TAG="" + ALIAS="" + if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then + TAG="${BASE_VERSION}${DEV_SUFFIX}" + ALIAS="dev" + elif [ "${GITHUB_REF}" = "refs/heads/master" ]; then + TAG="${BASE_VERSION}" + ALIAS="latest" + fi + + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT" + + - name: Login to registry + if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }} + env: + REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login git.tgj.services -u petegregoryy --password-stdin + + - name: Build and push web image + if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }} + env: + IMAGE: ${{ steps.web_meta.outputs.image }} + TAG: ${{ steps.web_meta.outputs.tag }} + ALIAS: ${{ steps.web_meta.outputs.alias }} + run: | + docker buildx create --name buildx --driver=docker-container --use || docker buildx use buildx + TAG_ARGS=(-t "${IMAGE}:${TAG}") + if [ -n "$ALIAS" ]; then + TAG_ARGS+=(-t "${IMAGE}:${ALIAS}") + fi + docker buildx build --builder buildx --platform linux/amd64 \ + -f Dockerfile.web \ + --push \ + "${TAG_ARGS[@]}" . + release-dev: runs-on: - mileograph @@ -262,6 +365,7 @@ jobs: - meta - android-build - linux-build + - web-build steps: - name: Install jq run: | @@ -345,6 +449,7 @@ jobs: - meta - android-build - linux-build + - web-build steps: - name: Install jq run: | diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..b0cb089 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,10 @@ +FROM nginx:1.27-alpine + +# Use a minimal Nginx image to serve the built Flutter web app. +# Assumes `flutter build web` has already populated build/web/ in the build context. +COPY deploy/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY build/web /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/web/nginx.conf b/deploy/web/nginx.conf new file mode 100644 index 0000000..405fe73 --- /dev/null +++ b/deploy/web/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + include /etc/nginx/mime.types; + + # Serve hashed assets aggressively; keep index/service worker cacheable but not immutable. + location /assets/ { + try_files $uri =404; + expires 30d; + add_header Cache-Control "public, max-age=2592000, immutable"; + } + + location /icons/ { + try_files $uri =404; + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + } + + location = /flutter_service_worker.js { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index 63b1ca8..ec4efff 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -111,12 +111,6 @@ class _LegCardState extends State { subtitle: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; - final timeWidget = _timeWithDelay( - context, - leg.beginTime, - leg.beginDelayMinutes, - includeDate: widget.showDate, - ); final tractionWrap = !_expanded && leg.locos.isNotEmpty ? Wrap( spacing: 8, diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 2907100..9604815 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -128,10 +128,6 @@ class _NewEntryPageState extends State { } } - double _manualMilesFromInput(DistanceUnitService units) { - return units.milesFromInput(_mileageController.text) ?? 0; - } - double _milesFromInputWithUnit(DistanceUnit unit) { return DistanceFormatter(unit) .parseInputMiles(_mileageController.text.trim()) ?? @@ -276,6 +272,7 @@ class _NewEntryPageState extends State { ), ), ); + if (!mounted) return; if (result != null) { final units = _distanceUnits(context); setState(() { @@ -1536,43 +1533,6 @@ class _NewEntryPageState extends State { ); } - Widget _timeToggleBlock({ - required String label, - required bool value, - required ValueChanged? onChanged, - required String matchLabel, - required bool matchValue, - required ValueChanged? onMatchChanged, - required bool showMatch, - Widget? picker, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CheckboxListTile( - value: value, - onChanged: onChanged, - dense: true, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - title: Text(label), - ), - if (showMatch) - CheckboxListTile( - value: matchValue, - onChanged: onMatchChanged, - dense: true, - contentPadding: const EdgeInsets.only(left: 12), - controlAffinity: ListTileControlAffinity.leading, - title: Text(matchLabel), - ), - if (picker != null) ...[ - const SizedBox(height: 6), - picker, - ], - ], - ); - } } class _UpperCaseTextFormatter extends TextInputFormatter { diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index b43f828..e20f357 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -52,6 +52,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { if (form == null) return; if (!form.validate()) return; if (!await _validateRequiredFields()) return; + if (!mounted) return; final routeStations = _routeResult?.calculatedRoute ?? []; final startVal = _useManualMileage ? _startController.text.trim() diff --git a/lib/components/pages/new_traction.dart b/lib/components/pages/new_traction.dart index 1bf8f25..e4a38c8 100644 --- a/lib/components/pages/new_traction.dart +++ b/lib/components/pages/new_traction.dart @@ -84,6 +84,13 @@ class _NewTractionPageState extends State { 'traction_motors': TextEditingController(), 'build_date': TextEditingController(), }; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final data = context.read(); + if (data.locoClasses.isEmpty) { + data.fetchClassList(); + } + }); } @override @@ -254,6 +261,10 @@ class _NewTractionPageState extends State { @override Widget build(BuildContext context) { final isActive = _statusIsActive; + final data = context.watch(); + final classOptions = [...data.locoClasses]..sort( + (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); final size = MediaQuery.of(context).size; final isNarrow = size.width < 720; final fieldWidth = isNarrow ? double.infinity : 340.0; @@ -269,6 +280,89 @@ class _NewTractionPageState extends State { double? widthOverride, String? Function(String?)? validator, }) { + // Special autocomplete for class field using existing loco classes. + if (key == 'class' && classOptions.isNotEmpty) { + return SizedBox( + width: widthOverride ?? fieldWidth, + child: Autocomplete( + optionsBuilder: (TextEditingValue value) { + final query = value.text.trim().toLowerCase(); + if (query.isEmpty) return classOptions; + return classOptions.where( + (c) => c.toLowerCase().contains(query), + ); + }, + onSelected: (selection) { + _controllers[key]?.text = selection; + _formKey.currentState?.validate(); + }, + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) { + if (textEditingController.text != _controllers[key]?.text) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (textEditingController.text != _controllers[key]?.text) { + textEditingController.value = + _controllers[key]?.value ?? textEditingController.value; + } + }); + } + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + decoration: InputDecoration( + labelText: required ? '$label *' : label, + helperText: helper, + suffixText: suffixText, + border: const OutlineInputBorder(), + ), + keyboardType: keyboardType, + maxLines: maxLines, + validator: (val) { + if (required && (val == null || val.trim().isEmpty)) { + return 'Required'; + } + return validator?.call(val); + }, + onChanged: (_) { + _controllers[key]?.text = textEditingController.text; + _formKey.currentState?.validate(); + }, + onFieldSubmitted: (_) => onFieldSubmitted(), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + final opts = options.toList(); + if (opts.isEmpty) return const SizedBox.shrink(); + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: 280, + maxWidth: widthOverride ?? fieldWidth, + ), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: opts.length, + itemBuilder: (context, index) { + final option = opts[index]; + return ListTile( + dense: true, + title: Text(option), + onTap: () => onSelected(option), + ); + }, + ), + ), + ), + ); + }, + ), + ); + } + return SizedBox( width: widthOverride ?? fieldWidth, child: TextFormField( diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 8739439..bc35275 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -37,6 +37,9 @@ class _TractionPageState extends State { final Map _dynamicControllers = {}; final Map _enumSelections = {}; bool _restoredFromPrefs = false; + static const int _pageSize = 100; + int _lastTractionOffset = 0; + String? _lastQuerySignature; @override void initState() { @@ -59,6 +62,9 @@ class _TractionPageState extends State { Future _initialLoad() async { final data = context.read(); await _restoreSearchState(); + if (_lastTractionOffset == 0 && data.traction.length > _pageSize) { + _lastTractionOffset = data.traction.length - _pageSize; + } data.fetchClassList(); data.fetchEventFields(); await _refreshTraction(); @@ -103,7 +109,29 @@ class _TractionPageState extends State { dynamicFieldsUsed; } - Future _refreshTraction({bool append = false}) async { + String _tractionQuerySignature( + Map filters, + bool hadOnly, + ) { + final sortedKeys = filters.keys.toList()..sort(); + final filterSignature = sortedKeys + .map((key) => '$key=${filters[key]}') + .join('|'); + final classQuery = (_selectedClass ?? _classController.text).trim(); + return [ + 'class=$classQuery', + 'number=${_numberController.text.trim()}', + 'name=${_nameController.text.trim()}', + 'mileageFirst=$_mileageFirst', + 'hadOnly=$hadOnly', + 'filters=$filterSignature', + ].join(';'); + } + + Future _refreshTraction({ + bool append = false, + bool preservePosition = true, + }) async { final data = context.read(); final filters = {}; final name = _nameController.text.trim(); @@ -118,15 +146,49 @@ class _TractionPageState extends State { } }); final hadOnly = !_hasFilters; + final signature = _tractionQuerySignature(filters, hadOnly); + final queryChanged = + _lastQuerySignature != null && signature != _lastQuerySignature; + _lastQuerySignature = signature; + + if (queryChanged && !append) { + _lastTractionOffset = 0; + } + + final shouldPreservePosition = preservePosition && + !append && + !queryChanged && + _lastTractionOffset > 0; + + int limit; + int offset; + if (append) { + offset = data.traction.length; + limit = _pageSize; + _lastTractionOffset = offset; + } else if (shouldPreservePosition) { + offset = 0; + limit = _pageSize + _lastTractionOffset; + } else { + offset = 0; + limit = _pageSize; + } + await data.fetchTraction( hadOnly: hadOnly, locoClass: _selectedClass ?? _classController.text.trim(), locoNumber: _numberController.text.trim(), - offset: append ? data.traction.length : 0, + offset: offset, + limit: limit, append: append, filters: filters, mileageFirst: _mileageFirst, ); + + if (!append && !shouldPreservePosition) { + _lastTractionOffset = 0; + } + await _persistSearchState(); } diff --git a/lib/components/pages/traction/traction_persistence.dart b/lib/components/pages/traction/traction_persistence.dart index 41952ae..7a35c75 100644 --- a/lib/components/pages/traction/traction_persistence.dart +++ b/lib/components/pages/traction/traction_persistence.dart @@ -39,6 +39,16 @@ extension _TractionPersistence on _TractionPageState { enumValues[entry.key.toString()] = entry.value?.toString(); } } + final lastOffsetRaw = decoded['lastOffset']; + if (lastOffsetRaw is int) { + _lastTractionOffset = lastOffsetRaw; + } else if (lastOffsetRaw is num) { + _lastTractionOffset = lastOffsetRaw.toInt(); + } + final lastSig = decoded['querySignature']?.toString(); + if (lastSig != null && lastSig.isNotEmpty) { + _lastQuerySignature = lastSig; + } for (final entry in dynamicValues.entries) { _dynamicControllers.putIfAbsent( @@ -76,6 +86,8 @@ extension _TractionPersistence on _TractionPageState { 'showAdvancedFilters': _showAdvancedFilters, 'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)), 'enum': _enumSelections, + 'lastOffset': _lastTractionOffset, + 'querySignature': _lastQuerySignature, }; try { diff --git a/lib/services/data_service/data_service_traction.dart b/lib/services/data_service/data_service_traction.dart index 6b2bf8d..f5d5da5 100644 --- a/lib/services/data_service/data_service_traction.dart +++ b/lib/services/data_service/data_service_traction.dart @@ -13,7 +13,7 @@ extension DataServiceTraction on DataService { Future fetchTraction({ bool hadOnly = false, int offset = 0, - int limit = 50, + int limit = 100, String? locoClass, String? locoNumber, bool mileageFirst = true, diff --git a/lib/services/distance_unit_service.dart b/lib/services/distance_unit_service.dart index e71589e..87c146e 100644 --- a/lib/services/distance_unit_service.dart +++ b/lib/services/distance_unit_service.dart @@ -158,7 +158,7 @@ class DistanceFormatter { return NumberFormat(pattern); } - String _formatMilesChains(double miles, {int decimals = 1}) { + String _formatMilesChains(double miles) { final totalChains = miles * DistanceUnitService.chainsPerMile; var milesPart = totalChains ~/ DistanceUnitService.chainsPerMile; final chainRemainder = diff --git a/web/index.html b/web/index.html index b0489a4..2bf5121 100644 --- a/web/index.html +++ b/web/index.html @@ -18,18 +18,19 @@ - + + - + - mileograph_flutter + Mileograph diff --git a/web/manifest.json b/web/manifest.json index 1e79d79..306d277 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,11 +1,12 @@ { - "name": "mileograph_flutter", - "short_name": "mileograph_flutter", - "start_url": ".", + "name": "Mileograph", + "short_name": "Mileograph", + "start_url": "/", + "scope": "/", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "Log and explore your Mileograph journeys.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [