From b6736725d1cd2b0dc14b18b42daee5ce10d922e3 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Wed, 8 Apr 2026 20:44:39 +1000 Subject: [PATCH 1/9] share locations to others --- lib/app_scaffold.dart | 14 + lib/models/external_place.dart | 123 +++++++ lib/models/external_places_call_result.dart | 41 +++ lib/services/places/places_fetch_service.dart | 10 +- lib/services/places/places_pod_file.dart | 30 +- lib/services/places/places_write_service.dart | 3 +- lib/services/sharing/sharing_service.dart | 257 ++++++++++++++ lib/widgets/locations/place_list_tile.dart | 8 + lib/widgets/locations_page.dart | 15 + lib/widgets/sharing/list_external_places.dart | 333 ++++++++++++++++++ .../sharing/list_external_places_screen.dart | 291 +++++++++++++++ lib/widgets/sharing/share_external_place.dart | 91 +++++ lib/widgets/sharing/share_place.dart | 104 ++++++ lib/widgets/sharing/view_external_place.dart | 247 +++++++++++++ 14 files changed, 1549 insertions(+), 18 deletions(-) create mode 100644 lib/models/external_place.dart create mode 100644 lib/models/external_places_call_result.dart create mode 100644 lib/services/sharing/sharing_service.dart create mode 100644 lib/widgets/sharing/list_external_places.dart create mode 100644 lib/widgets/sharing/list_external_places_screen.dart create mode 100644 lib/widgets/sharing/share_external_place.dart create mode 100644 lib/widgets/sharing/share_place.dart create mode 100644 lib/widgets/sharing/view_external_place.dart diff --git a/lib/app_scaffold.dart b/lib/app_scaffold.dart index d434d26..ba59a73 100644 --- a/lib/app_scaffold.dart +++ b/lib/app_scaffold.dart @@ -37,6 +37,7 @@ import 'widgets/audio_page.dart'; import 'widgets/files_page.dart'; import 'widgets/geomap.dart'; import 'widgets/locations_page.dart'; +import 'widgets/sharing/list_external_places_screen.dart'; import 'widgets/video_page.dart'; /// App scaffold widget that responds to fullscreen mode changes. @@ -96,6 +97,19 @@ class AppScaffoldWidget extends StatelessWidget { ''', child: LocationsPage(), ), + const SolidMenuItem( + icon: Icons.share, + title: 'Shared', + tooltip: ''' + + **Shared:** Tap here to see places that other Pod users have shared + with you. You can view the place details, coordinates, and your + permission level. If you have control access, you can re-share + the place with others. + + ''', + child: ListExternalPlacesScreen(), + ), const SolidMenuItem( icon: Icons.headphones, title: 'Audio', diff --git a/lib/models/external_place.dart b/lib/models/external_place.dart new file mode 100644 index 0000000..f5c6371 --- /dev/null +++ b/lib/models/external_place.dart @@ -0,0 +1,123 @@ +/// Data model for an externally owned place shared with the user. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +library; + +import 'package:geopod/models/place.dart'; + +/// Data model for an externally owned place shared with the user. + +class ExternalPlace { + /// The deserialized place data, populated after fetching the remote file. + Place? content; + + /// ISO-8601 timestamp when the permission was granted. + final String sharedTime; + + /// Full URL of the remote place JSON file. + final String placeUrl; + + /// Filename component (e.g. `place_.json`). + final String placeFileName; + + /// WebID of the Pod owner of the place. + final String placeOwner; + + /// WebID of the user who granted the permission. + final String permissionGranter; + + /// WebID of the user who received the permission. + final String permissionRecepient; + + /// Permission type string (e.g. `grant`). + final String permissionType; + + /// Comma-separated list of access modes (e.g. `read,write`). + final String permissionList; + + ExternalPlace({ + this.content, + required this.sharedTime, + required this.placeUrl, + required this.placeFileName, + required this.placeOwner, + required this.permissionGranter, + required this.permissionRecepient, + required this.permissionType, + required this.permissionList, + }); + + /// Returns a copy with the [content] field replaced. + ExternalPlace withContent(Place? newContent) => ExternalPlace( + content: newContent, + sharedTime: sharedTime, + placeUrl: placeUrl, + placeFileName: placeFileName, + placeOwner: placeOwner, + permissionGranter: permissionGranter, + permissionRecepient: permissionRecepient, + permissionType: permissionType, + permissionList: permissionList, + ); + + /// Returns a copy promoted to a [FoundExternalPlace]. + FoundExternalPlace toFound({bool isSelected = false}) => FoundExternalPlace( + content: content, + sharedTime: sharedTime, + placeUrl: placeUrl, + placeFileName: placeFileName, + placeOwner: placeOwner, + permissionGranter: permissionGranter, + permissionRecepient: permissionRecepient, + permissionType: permissionType, + permissionList: permissionList, + isSelected: isSelected, + ); +} + +/// An [ExternalPlace] that carries an additional [isSelected] flag, +/// used by list widgets for multi-selection UI. + +class FoundExternalPlace extends ExternalPlace { + bool isSelected; + + FoundExternalPlace({ + required super.sharedTime, + required super.placeUrl, + required super.placeFileName, + required super.placeOwner, + required super.permissionGranter, + required super.permissionRecepient, + required super.permissionType, + required super.permissionList, + super.content, + this.isSelected = false, + }); +} + +/// Extension to convert a [List] to a +/// [List]. +extension ExternalPlaceListExtension on List { + List toListFoundExternalPlace() => + map((p) => p.toFound()).toList(); +} diff --git a/lib/models/external_places_call_result.dart b/lib/models/external_places_call_result.dart new file mode 100644 index 0000000..a23dfc4 --- /dev/null +++ b/lib/models/external_places_call_result.dart @@ -0,0 +1,41 @@ +/// Result model for external places future call. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +library; + +import 'package:geopod/models/external_place.dart'; + +/// Return value of [getExternalPlaceList]. +/// +/// - [places] — successfully loaded external places. +/// - [nonExistentPlaces] — places where access is still logged but the +/// remote file has already been deleted by its owner. +/// - [forbiddenPlaces] — places the current user has no permission to read +/// (ACL issue: sharing may have partially failed). +/// - [encryptionErrorPlaces] — encrypted places whose decryption key is +/// unavailable (shared-keys.ttl absent) or whose security key was not yet +/// set when the read was attempted. +/// - [unparseablePlaces] — places whose JSON file could not be parsed. + +class ExternalPlacesCallResult { + final List? places; + final List? nonExistentPlaces; + final List? forbiddenPlaces; + final List? encryptionErrorPlaces; + final List? unparseablePlaces; + + const ExternalPlacesCallResult({ + this.places = const [], + this.nonExistentPlaces = const [], + this.forbiddenPlaces = const [], + this.encryptionErrorPlaces = const [], + this.unparseablePlaces = const [], + }); +} diff --git a/lib/services/places/places_fetch_service.dart b/lib/services/places/places_fetch_service.dart index 3bb9ced..0bf8347 100644 --- a/lib/services/places/places_fetch_service.dart +++ b/lib/services/places/places_fetch_service.dart @@ -25,6 +25,7 @@ library; +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -197,7 +198,11 @@ class PlacesFetchService { final places = []; try { final content = await readPlacesJsonFile(); - if (content == null || content.trim().isEmpty) return places; + if (content == null || content.trim().isEmpty) { + // Pod has no places.json yet — auto-create an empty one. + unawaited(writePlacesJsonFile('[]')); + return places; + } final decoded = jsonDecode(content); if (decoded is List) { for (final i in decoded) { @@ -226,6 +231,9 @@ class PlacesFetchService { final c = await readPlacesJsonFile(); if (c != null && c.trim().isNotEmpty) { await PlacesCachePersistence.cachePodPlaces(c); + } else { + // Pod returned nothing — auto-create empty places.json. + unawaited(writePlacesJsonFile('[]')); } } catch (_) {} }); diff --git a/lib/services/places/places_pod_file.dart b/lib/services/places/places_pod_file.dart index 13478b8..6ac8098 100644 --- a/lib/services/places/places_pod_file.dart +++ b/lib/services/places/places_pod_file.dart @@ -91,25 +91,23 @@ Future writePlacesJsonFile(String content) async { } /// Write an individual place file. +/// +/// Uses solidpod's [writePod] so that: +/// - The file is created or overwritten via an authenticated PUT. +/// - A `.acl` file is automatically created when it doesn't exist yet, +/// which is required before the file can be shared via [GrantPermissionUi]. -Future writeIndividualPlaceFile(Place place) async { +Future writeIndividualPlaceFile(Place place, {bool createAcl = true}) async { try { - final fp = await getIndividualPlaceFilePath(place.id); - final url = await getFileUrl(fp); - final (:accessToken, :dPopToken) = await getTokensForResource(url, 'PUT'); - final r = await http.put( - Uri.parse(url), - headers: { - 'Accept': '*/*', - 'Authorization': 'DPoP $accessToken', - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'DPoP': dPopToken, - }, - body: jsonEncode(place.toJson()), + await writePod( + 'places/place_${place.id}.json', + jsonEncode(place.toJson()), + encrypted: false, + createAcl: createAcl, + overwrite: true, ); - debugPrint('Write individual place file: $fp, status: ${r.statusCode}'); - return r.statusCode >= 200 && r.statusCode < 300; + debugPrint('Write individual place file: places/place_${place.id}.json'); + return true; } catch (e) { debugPrint('Error writing individual place file: $e'); return false; diff --git a/lib/services/places/places_write_service.dart b/lib/services/places/places_write_service.dart index b4b5e29..e4c69e2 100644 --- a/lib/services/places/places_write_service.dart +++ b/lib/services/places/places_write_service.dart @@ -262,10 +262,11 @@ class PlacesWriteService { } // Update both main file and individual file in parallel. + // createAcl: false — ACL already exists from the initial addPlace write. final newJson = jsonEncode(list.map((p) => p.toJson()).toList()); final results = await Future.wait([ writePlacesJsonFile(newJson), - writeIndividualPlaceFile(toSave), + writeIndividualPlaceFile(toSave, createAcl: false), ]); final success = results[0]; diff --git a/lib/services/sharing/sharing_service.dart b/lib/services/sharing/sharing_service.dart new file mode 100644 index 0000000..31b50c3 --- /dev/null +++ b/lib/services/sharing/sharing_service.dart @@ -0,0 +1,257 @@ +/// Sharing service: fetches external places shared with the user. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +library; + +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:geopod/models/external_place.dart'; +import 'package:geopod/models/external_places_call_result.dart'; +import 'package:geopod/models/place.dart'; + +/// Reads the shared-resources permission log and returns external places +/// whose URLs look like geopod place files (`/places/place_`). +/// +/// Returns an empty map on any error or when logged out. + +Future> scanPermLogFile() async { + try { + final result = await sharedResources(); + if (result == SolidFunctionCallStatus.notLoggedIn) return {}; + return result as Map; + } catch (e) { + debugPrint('[SharingService] scanPermLogFile error: $e'); + return {}; + } +} + +/// Parses a single log entry into an [ExternalPlace]. +/// +/// Returns `null` if any required field is missing. + +ExternalPlace? extPlaceDetailsFromLog({ + required Map logRecordOfFile, + required String fileUrl, +}) { + try { + String? sharedTime; + String? placeUrl; + String? placeOwner; + String? permissionGranter; + String? permissionRecepient; + String? permissionType; + String? permissionList; + + final placeFileName = fileUrl.split('/').last; + + for (final entry in logRecordOfFile.entries) { + final predicate = entry.key.toString(); + final value = entry.value.toString(); + + if (predicate.contains(PermissionLogLiteral.logtime.toString())) { + sharedTime = value; + } else if (predicate.contains(PermissionLogLiteral.resource.toString())) { + placeUrl = value; + } else if (predicate.contains(PermissionLogLiteral.owner.toString())) { + placeOwner = value; + } else if (predicate.contains(PermissionLogLiteral.granter.toString())) { + permissionGranter = value; + } else if (predicate + .contains(PermissionLogLiteral.recepient.toString())) { + permissionRecepient = value; + } else if (predicate.contains(PermissionLogLiteral.type.toString())) { + permissionType = value; + } else if (predicate + .contains(PermissionLogLiteral.permissions.toString())) { + permissionList = value; + } + } + + return ExternalPlace( + sharedTime: sharedTime!, + placeUrl: placeUrl!, + placeFileName: placeFileName, + placeOwner: placeOwner!, + permissionGranter: permissionGranter!, + permissionRecepient: permissionRecepient!, + permissionType: permissionType!, + permissionList: permissionList!, + ); + } catch (e) { + debugPrint('[SharingService] extPlaceDetailsFromLog error: $e'); + return null; + } +} + +/// Fetches the content of an external place file and returns the populated +/// [ExternalPlace] on success, or a [FileCallStatus] on failure. + +Future getExternalPlaceContent(ExternalPlace place) async { + try { + final raw = await readPod( + place.placeUrl, + pathType: PathType.absoluteUrl, + ); + + // If readPod returned raw encrypted TTL (decryption key missing on + // recipient's pod — shared-keys.ttl absent or key not found), detect it + // before attempting JSON decode. + final trimmed = raw.trimLeft(); + if (trimmed.startsWith('@prefix') || trimmed.startsWith('@base')) { + debugPrint( + '[SharingService] Encrypted TTL returned undecrypted ' + '(shared key missing): ${place.placeUrl}', + ); + return FileCallStatus.decryptionKeyMissing; + } + + final decoded = jsonDecode(raw) as Map; + final content = Place.fromJson(decoded); + return place.withContent(content); + } on ResourceNotExistException catch (e) { + debugPrint('[SharingService] Resource not found: $e'); + return FileCallStatus.fileNotExists; + } on AccessForbiddenException catch (e) { + debugPrint('[SharingService] Access forbidden (ACL not granted?): $e'); + return FileCallStatus.accessForbidden; + } catch (e) { + // Detect security key not yet set (thrown as plain Exception, not typed). + if (e.toString().toLowerCase().contains('security key')) { + debugPrint('[SharingService] Security key not available yet: $e'); + return FileCallStatus.securityKeyNotAvailable; + } + debugPrint('[SharingService] getExternalPlaceContent error: $e'); + return FileCallStatus.parsingFail; + } +} + +/// Returns the full list of external places shared with the current user. +/// +/// Only entries whose URL contains `/places/place_` are considered geopod +/// place files; all others are silently ignored. +/// +/// Skips entries whose access has been revoked (`permissionType == 'revoke'`). + +Future getExternalPlaceList({ + bool hasCurrentAccess = true, +}) async { + final logMap = await scanPermLogFile(); + if (logMap.isEmpty) return const ExternalPlacesCallResult(); + + final List places = []; + final List unparseableLogRecords = []; + + for (final fileUrl in logMap.keys) { + final urlStr = fileUrl.toString(); + + // Filter only geopod place files (plain or encrypted). + final isPlainPlace = urlStr.contains('/places/place_'); + final isEncPlace = urlStr.contains('/encrypted_data/enc_place_'); + if (!isPlainPlace && !isEncPlace) continue; + + final logRecord = + logMap[fileUrl] as Map; + + // Skip revoked entries if caller only wants current access. + if (hasCurrentAccess && + logRecord[PermissionLogLiteral.type] == 'revoke') { + continue; + } + + try { + final place = extPlaceDetailsFromLog( + logRecordOfFile: logRecord, + fileUrl: urlStr, + ); + if (place != null) { + places.add(place); + } else { + unparseableLogRecords.add(urlStr); + } + } catch (e) { + debugPrint('[SharingService] Error parsing log record: $e'); + } + } + + if (places.isEmpty) return const ExternalPlacesCallResult(); + + // Fetch each place's content concurrently. + final futures = places.map(getExternalPlaceContent).toList(); + final results = await Future.wait(futures); + + final List fullPlaces = []; + final List nonExistentPlaces = []; + final List forbiddenPlaces = []; + final List encryptionErrorPlaces = []; + final List unparseablePlaces = []; + + for (int i = 0; i < results.length; i++) { + final r = results[i]; + if (r is ExternalPlace) { + fullPlaces.add(r); + } else if (r == FileCallStatus.fileNotExists) { + nonExistentPlaces.add(places[i]); + } else if (r == FileCallStatus.accessForbidden) { + forbiddenPlaces.add(places[i]); + } else if (r == FileCallStatus.decryptionKeyMissing || + r == FileCallStatus.securityKeyNotAvailable) { + encryptionErrorPlaces.add(places[i]); + } else { + unparseablePlaces.add(places[i]); + } + } + + debugPrint( + '[SharingService] Loaded ${fullPlaces.length} external places, ' + '${nonExistentPlaces.length} non-existent, ' + '${encryptionErrorPlaces.length} encryption-error, ' + '${unparseablePlaces.length} unparseable.', + ); + + return ExternalPlacesCallResult( + places: fullPlaces, + nonExistentPlaces: nonExistentPlaces, + forbiddenPlaces: forbiddenPlaces, + encryptionErrorPlaces: encryptionErrorPlaces, + unparseablePlaces: unparseablePlaces, + ); +} + +/// Status codes recycled from solidpod for internal use. +enum FileCallStatus { + success, + fail, + fileNotExists, + accessForbidden, + /// The resource is an encrypted TTL file but the decryption key is not + /// available on this user's pod (shared-keys.ttl absent or key not found). + decryptionKeyMissing, + /// The security key has not been set / cached yet when the read was + /// attempted. Refreshing after the key is ready should resolve this. + securityKeyNotAvailable, + parsingFail, +} diff --git a/lib/widgets/locations/place_list_tile.dart b/lib/widgets/locations/place_list_tile.dart index 4d4f882..c575beb 100644 --- a/lib/widgets/locations/place_list_tile.dart +++ b/lib/widgets/locations/place_list_tile.dart @@ -46,11 +46,13 @@ class PlaceListTile extends StatelessWidget { required this.place, this.onEdit, this.onDelete, + this.onShare, }); final Place place; final VoidCallback? onEdit; final VoidCallback? onDelete; + final VoidCallback? onShare; @override Widget build(BuildContext context) { @@ -122,6 +124,12 @@ class PlaceListTile extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (onShare != null) + IconButton( + icon: Icon(Icons.share_outlined, color: Colors.green.shade600), + onPressed: onShare, + tooltip: 'Share', + ), if (onEdit != null) IconButton( icon: Icon(Icons.edit_outlined, color: Colors.blue.shade600), diff --git a/lib/widgets/locations_page.dart b/lib/widgets/locations_page.dart index ef8cfb6..3a05e4b 100644 --- a/lib/widgets/locations_page.dart +++ b/lib/widgets/locations_page.dart @@ -26,6 +26,7 @@ import 'package:geopod/widgets/locations/locations_page_header.dart'; import 'package:geopod/widgets/locations/locations_page_views.dart'; import 'package:geopod/widgets/locations/place_list_tile.dart'; import 'package:geopod/widgets/locations/place_operations.dart'; +import 'package:geopod/widgets/sharing/share_place.dart'; class LocationsPage extends StatefulWidget { const LocationsPage({super.key}); @@ -440,6 +441,20 @@ class _LocationsPageState extends State onDelete: isLoggedIn && !p.isLocal ? () => _deletePlace(p) : null, + // Allow share when logged in and place is not local. + // Encrypted places are also shareable: solidpod's + // grantPermission() automatically shares the individual + // encryption key with the recipient. + onShare: isLoggedIn && !p.isLocal + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SharePlace( + place: p, + backPage: const LocationsPage(), + ), + ), + ) + : null, ); }, ), diff --git a/lib/widgets/sharing/list_external_places.dart b/lib/widgets/sharing/list_external_places.dart new file mode 100644 index 0000000..5577b8c --- /dev/null +++ b/lib/widgets/sharing/list_external_places.dart @@ -0,0 +1,333 @@ +/// Widget that lists external places shared with the current user. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +library; + +import 'package:flutter/material.dart'; + +import 'package:geopod/models/external_place.dart'; +import 'package:geopod/widgets/sharing/view_external_place.dart'; + +/// A stateful list widget for external places shared with the user. +/// +/// Supports searching by file name, owner, granter, or permission list. + +class ListExternalPlaces extends StatefulWidget { + const ListExternalPlaces({ + super.key, + required this.places, + required this.listPage, + }); + + /// List of successfully loaded external places. + final List places; + + /// The list screen widget (passed through for Back navigation). + final Widget listPage; + + @override + State createState() => _ListExternalPlacesState(); +} + +class _ListExternalPlacesState extends State { + late List _foundPlaces; + final TextEditingController _searchController = TextEditingController(); + bool _sortNameAscending = true; + bool _sortOwnerAscending = true; + + @override + void initState() { + super.initState(); + _foundPlaces = widget.places.toListFoundExternalPlace(); + _sortByName(true); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _sortByName(bool ascending) { + setState(() { + _sortNameAscending = ascending; + _foundPlaces.sort((a, b) { + final aName = a.content?.displayTitle ?? a.placeFileName; + final bName = b.content?.displayTitle ?? b.placeFileName; + return ascending + ? aName.toLowerCase().compareTo(bName.toLowerCase()) + : bName.toLowerCase().compareTo(aName.toLowerCase()); + }); + }); + } + + void _sortByOwner(bool ascending) { + setState(() { + _sortOwnerAscending = ascending; + _foundPlaces.sort((a, b) => ascending + ? a.placeOwner.toLowerCase().compareTo(b.placeOwner.toLowerCase()) + : b.placeOwner.toLowerCase().compareTo(a.placeOwner.toLowerCase())); + }); + } + + void _searchPlaces(String keyword) { + final all = widget.places.toListFoundExternalPlace(); + setState(() { + if (keyword.isEmpty) { + _foundPlaces = all; + } else { + final kw = keyword.toLowerCase(); + _foundPlaces = all.where((p) { + final name = (p.content?.displayTitle ?? p.placeFileName).toLowerCase(); + return name.contains(kw) || + p.placeOwner.toLowerCase().contains(kw) || + p.permissionGranter.toLowerCase().contains(kw) || + p.permissionList.toLowerCase().contains(kw); + }).toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // ── Header ──────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + const Icon(Icons.share, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Places Shared With Me (${_foundPlaces.length})', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + + // ── Search bar ──────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: TextField( + controller: _searchController, + onChanged: _searchPlaces, + decoration: InputDecoration( + hintText: 'Search by name, owner, or permissions…', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _searchPlaces(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + isDense: true, + ), + ), + ), + + // ── Sort row ────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: Row( + children: [ + const Text('Sort:', style: TextStyle(fontSize: 12)), + const SizedBox(width: 4), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + ), + icon: Icon( + _sortNameAscending + ? Icons.arrow_upward + : Icons.arrow_downward, + size: 14, + ), + label: const Text('Name', style: TextStyle(fontSize: 12)), + onPressed: () => _sortByName(!_sortNameAscending), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + ), + icon: Icon( + _sortOwnerAscending + ? Icons.arrow_upward + : Icons.arrow_downward, + size: 14, + ), + label: const Text('Owner', style: TextStyle(fontSize: 12)), + onPressed: () => _sortByOwner(!_sortOwnerAscending), + ), + ], + ), + ), + + const Divider(height: 1), + + // ── Place list ──────────────────────────────────────────────────── + Expanded( + child: _foundPlaces.isEmpty + ? const Center( + child: Text( + 'No matching places found.', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + itemCount: _foundPlaces.length, + itemBuilder: (context, index) { + final place = _foundPlaces[index]; + return _ExternalPlaceCard( + place: place, + listPage: widget.listPage, + ); + }, + ), + ), + ], + ); + } +} + +/// A card tile for a single external place. + +class _ExternalPlaceCard extends StatelessWidget { + const _ExternalPlaceCard({ + required this.place, + required this.listPage, + }); + + final FoundExternalPlace place; + final Widget listPage; + + @override + Widget build(BuildContext context) { + final content = place.content; + final accessModes = place.permissionList + .split(',') + .map((s) => s.trim()) + .toList(); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.teal.shade400, + child: const Icon(Icons.place, color: Colors.white), + ), + title: Text( + content?.displayTitle ?? place.placeFileName, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (content?.address != null) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon(Icons.home_outlined, size: 13, color: Colors.blue.shade600), + const SizedBox(width: 4), + Expanded( + child: Text( + content!.shortAddress, + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + const SizedBox(height: 2), + Row( + children: [ + Icon(Icons.person_outline, size: 13, color: Colors.grey.shade500), + const SizedBox(width: 4), + Expanded( + child: Text( + place.placeOwner, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 2), + Wrap( + spacing: 4, + children: accessModes + .where((m) => m.isNotEmpty) + .map( + (m) => Chip( + label: Text(m, style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + backgroundColor: _modeColor(m), + labelStyle: const TextStyle(color: Colors.white), + ), + ) + .toList(), + ), + ], + ), + isThreeLine: true, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ViewExternalPlace( + place: place, + listPage: listPage, + ), + ), + ), + ), + ); + } + + Color _modeColor(String mode) { + switch (mode.toLowerCase()) { + case 'read': + return Colors.blue.shade400; + case 'write': + return Colors.orange.shade400; + case 'append': + return Colors.green.shade400; + case 'control': + return Colors.purple.shade400; + default: + return Colors.grey.shade400; + } + } +} diff --git a/lib/widgets/sharing/list_external_places_screen.dart b/lib/widgets/sharing/list_external_places_screen.dart new file mode 100644 index 0000000..f70e996 --- /dev/null +++ b/lib/widgets/sharing/list_external_places_screen.dart @@ -0,0 +1,291 @@ +/// Screen that asynchronously loads and displays external places. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +library; + +import 'package:flutter/material.dart'; + +import 'package:geopod/models/external_places_call_result.dart'; +import 'package:geopod/services/sharing/sharing_service.dart'; +import 'package:geopod/widgets/sharing/list_external_places.dart'; + +/// A [StatefulWidget] that fetches external-place data in a [FutureBuilder] +/// and hands off to [ListExternalPlaces] once the data is ready. + +class ListExternalPlacesScreen extends StatefulWidget { + const ListExternalPlacesScreen({super.key}); + + @override + State createState() => + _ListExternalPlacesScreenState(); +} + +class _ListExternalPlacesScreenState extends State { + late Future _dataFuture; + + @override + void initState() { + super.initState(); + _dataFuture = getExternalPlaceList(); + } + + void _reload() { + setState(() { + _dataFuture = getExternalPlaceList(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: FutureBuilder( + future: _dataFuture, + builder: (context, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.waiting: + case ConnectionState.active: + return const _LoadingView(); + + case ConnectionState.done: + if (snapshot.hasError) { + debugPrint( + '[ListExternalPlacesScreen] Error: ${snapshot.error}', + ); + return _ErrorView( + message: snapshot.error.toString(), + onRetry: _reload, + ); + } + + final result = snapshot.data; + if (result == null) { + return _EmptyView(onRefresh: _reload); + } + + final places = result.places ?? []; + final nonExistent = result.nonExistentPlaces ?? []; + final forbidden = result.forbiddenPlaces ?? []; + final encryptionErrors = result.encryptionErrorPlaces ?? []; + final unparseable = result.unparseablePlaces ?? []; + + // Notify about places whose source files have been deleted. + if (nonExistent.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${nonExistent.length} shared place(s) no longer ' + 'exist on the owner\'s Pod.', + ), + action: SnackBarAction( + label: 'Dismiss', + onPressed: () {}, + ), + ), + ); + }); + } + + // Notify about places the current user has no access to. + // This usually means the owner's ACL was not properly set, + // or the sharing step completed only partially. + if (forbidden.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 8), + content: Text( + '${forbidden.length} shared place(s) could not be ' + 'loaded: access denied. Ask the owner to re-share ' + 'the place with you.', + ), + action: SnackBarAction( + label: 'Dismiss', + onPressed: () {}, + ), + ), + ); + }); + } + + // Notify about encrypted places whose decryption key is missing. + if (encryptionErrors.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 10), + content: Text( + '${encryptionErrors.length} encrypted place(s) ' + 'could not be decrypted. Ensure your security key ' + 'is set and refresh, or ask the owner to re-share.', + ), + action: SnackBarAction( + label: 'Refresh', + onPressed: _reload, + ), + ), + ); + }); + } + + // Notify about places that could not be parsed. + if (unparseable.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${unparseable.length} shared place(s) could not ' + 'be read (network or format error).', + ), + action: SnackBarAction( + label: 'Dismiss', + onPressed: () {}, + ), + ), + ); + }); + } + + if (places.isEmpty) { + return _EmptyView(onRefresh: _reload); + } + + return RefreshIndicator( + onRefresh: () async => _reload(), + child: ListExternalPlaces( + places: places, + listPage: const ListExternalPlacesScreen(), + ), + ); + + case ConnectionState.none: + return _ErrorView( + message: 'Connection error.', + onRetry: _reload, + ); + } + }, + ), + ), + ); + } +} + +/// Loading spinner. + +class _LoadingView extends StatelessWidget { + const _LoadingView(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading shared places…'), + ], + ), + ); + } +} + +/// Shown when no shared places exist. + +class _EmptyView extends StatelessWidget { + const _EmptyView({required this.onRefresh}); + + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.share_outlined, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text( + 'No Shared Places', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Places shared with you from other Pods will appear here. ' + 'Ask someone to share a place with you, or check your ' + "Pod's permission log.", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + onPressed: onRefresh, + ), + ], + ), + ), + ); + } +} + +/// Shown on error. + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red.shade400, + ), + const SizedBox(height: 16), + const Text( + 'Failed to Load Shared Places', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + onPressed: onRetry, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sharing/share_external_place.dart b/lib/widgets/sharing/share_external_place.dart new file mode 100644 index 0000000..5caa843 --- /dev/null +++ b/lib/widgets/sharing/share_external_place.dart @@ -0,0 +1,91 @@ +/// Widget for re-sharing an externally owned place. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/solidui.dart'; + +import 'package:geopod/models/external_place.dart'; + +/// Wraps [GrantPermissionUi] so the current user can re-share an external +/// place (i.e. a place owned by someone else that was shared with them and +/// for which they hold `control` permission). + +class ShareExternalPlace extends StatelessWidget { + const ShareExternalPlace({ + super.key, + required this.place, + required this.backPage, + }); + + /// The external place to re-share. + final FoundExternalPlace place; + + /// The widget to return to when the user presses Back. + final Widget backPage; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'Re-Share Place', + style: TextStyle(fontWeight: FontWeight.bold), + ), + leading: BackButton( + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Re-sharing: ${place.content?.displayTitle ?? place.placeFileName}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Owner: ${place.placeOwner}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ), + const SizedBox(height: 8), + const Divider(height: 1), + Expanded( + // For external resources, resourceName must be the full URL + // and isExternalRes must be true. + child: GrantPermissionUi( + showAppBar: false, + resourceName: place.placeUrl, + isExternalRes: true, + ownerWebId: place.placeOwner, + granterWebId: place.permissionGranter, + child: ShareExternalPlace( + place: place, + backPage: backPage, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sharing/share_place.dart b/lib/widgets/sharing/share_place.dart new file mode 100644 index 0000000..342a50c --- /dev/null +++ b/lib/widgets/sharing/share_place.dart @@ -0,0 +1,104 @@ +/// Widget for sharing a place owned by the current user. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/solidui.dart'; + +import 'package:geopod/models/place.dart'; + +/// A widget that wraps [GrantPermissionUi] so the user can grant Pod-level +/// access to a specific place file. +/// +/// The individual place file is stored at path +/// `places/place_.json` inside the app's data directory; solidpod +/// resolves this to `geopod/data/places/place_.json` and creates the +/// corresponding `.acl` file. + +class SharePlace extends StatelessWidget { + const SharePlace({ + super.key, + required this.place, + required this.backPage, + }); + + /// The place to share. + final Place place; + + /// The widget to return to when the user presses Back. + final Widget backPage; + + @override + Widget build(BuildContext context) { + // resourceName is relative to the app's data directory. + // For plain places solidpod normalises it to + // geopod/data/places/place_.json + // For encrypted places it resolves to + // geopod/data/encrypted_data/enc_place_.ttl + // solidpod's grantPermission() detects whether the file is encrypted + // and automatically shares the individual encryption key with the + // recipient (encrypted with their public key). + final resourceName = place.isEncrypted + ? 'encrypted_data/enc_place_${place.id}.ttl' + : 'places/place_${place.id}.json'; + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Share Place', + style: TextStyle(fontWeight: FontWeight.bold), + ), + leading: BackButton( + onPressed: () => Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => backPage), + ), + ), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Sharing: ${place.displayTitle}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + place.displayAddress, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ), + const SizedBox(height: 8), + const Divider(height: 1), + Expanded( + child: GrantPermissionUi( + showAppBar: false, + resourceName: resourceName, + child: SharePlace( + place: place, + backPage: backPage, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sharing/view_external_place.dart b/lib/widgets/sharing/view_external_place.dart new file mode 100644 index 0000000..4565a57 --- /dev/null +++ b/lib/widgets/sharing/view_external_place.dart @@ -0,0 +1,247 @@ +/// Widget for viewing the details of an external place. +/// +// Time-stamp: <2026-04-08 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +library; + +import 'package:flutter/material.dart'; + +import 'package:geopod/models/external_place.dart'; +import 'package:geopod/widgets/sharing/share_external_place.dart'; + +/// A widget that shows the full details of an external place and provides +/// action buttons (re-share if control, back). + +class ViewExternalPlace extends StatelessWidget { + const ViewExternalPlace({ + super.key, + required this.place, + required this.listPage, + }); + + /// The external place to display. + final FoundExternalPlace place; + + /// The page to return to (usually [ListExternalPlacesScreen]). + final Widget listPage; + + @override + Widget build(BuildContext context) { + final accessModes = place.permissionList + .split(',') + .map((s) => s.trim().toLowerCase()) + .toList(); + + final content = place.content; + + return Scaffold( + appBar: AppBar( + title: Text( + content?.displayTitle ?? place.placeFileName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + leading: BackButton( + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Place data ────────────────────────────────────────────── + if (content != null) ...[ + const _SectionHeader(label: 'Place Details'), + _DetailRow( + icon: Icons.label_outline, + label: 'Title', + value: content.displayTitle, + ), + _DetailRow( + icon: Icons.location_on_outlined, + label: 'Coordinates', + value: content.coordinates, + ), + if (content.address != null) + _DetailRow( + icon: Icons.home_outlined, + label: 'Address', + value: content.address!, + ), + _DetailRow( + icon: Icons.calendar_today_outlined, + label: 'Saved on', + value: content.formattedDate, + ), + if (content.note.isNotEmpty && + content.note != content.displayTitle) + _DetailRow( + icon: Icons.notes_outlined, + label: 'Note', + value: content.note, + ), + const SizedBox(height: 16), + ] else ...[ + const Card( + child: Padding( + padding: EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.warning_amber_outlined, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + 'Place content could not be loaded.', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // ── Sharing metadata ───────────────────────────────────────── + const _SectionHeader(label: 'Sharing Details'), + _DetailRow( + icon: Icons.person_outline, + label: 'Owner', + value: place.placeOwner, + ), + _DetailRow( + icon: Icons.share_outlined, + label: 'Shared by', + value: place.permissionGranter, + ), + _DetailRow( + icon: Icons.access_time_outlined, + label: 'Shared at', + value: place.sharedTime, + ), + _DetailRow( + icon: Icons.security_outlined, + label: 'Permissions', + value: place.permissionList, + ), + + const SizedBox(height: 24), + + // ── Action buttons ──────────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Re-share button (only if current user has control) + if (accessModes.contains('control')) ...[ + ElevatedButton.icon( + icon: const Icon(Icons.share), + label: const Text('Re-Share'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ShareExternalPlace( + place: place, + backPage: ViewExternalPlace( + place: place, + listPage: listPage, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + ], + // Back button + OutlinedButton.icon( + icon: const Icon(Icons.arrow_back), + label: const Text('Back'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Simple section heading. + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.blueGrey.shade700, + letterSpacing: 0.5, + ), + ), + ); + } +} + +/// A single label + value row used inside [ViewExternalPlace]. + +class _DetailRow extends StatelessWidget { + const _DetailRow({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: Colors.blueGrey.shade400), + const SizedBox(width: 8), + SizedBox( + width: 90, + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + } +} From 24c5420e5636d0d978795a7d1c5a779c7d6a0ade Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Mon, 13 Apr 2026 10:20:59 +1000 Subject: [PATCH 2/9] revise the functions of sharing place --- lib/services/places/encrypted_places_io.dart | 10 +- lib/services/sharing/sharing_service.dart | 50 +++- lib/widgets/sharing/edit_external_place.dart | 272 ++++++++++++++++++ lib/widgets/sharing/list_external_places.dart | 40 ++- .../sharing/list_external_places_screen.dart | 21 +- lib/widgets/sharing/view_external_place.dart | 26 ++ 6 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 lib/widgets/sharing/edit_external_place.dart diff --git a/lib/services/places/encrypted_places_io.dart b/lib/services/places/encrypted_places_io.dart index bd79d8c..fd05b86 100644 --- a/lib/services/places/encrypted_places_io.dart +++ b/lib/services/places/encrypted_places_io.dart @@ -163,15 +163,19 @@ Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod( Future writeIndividualEncryptedPlaceFile(Place place) async { try { final filePath = getEncryptedIndividualPlaceFilePath(place.id); - final dirPath = getEncryptedPlacesDirPath(); final jsonContent = jsonEncode(place.toJson()); + // Use encrypted: true (per-file individual key) so that grantPermission() + // can correctly detect this file as encrypted via checkFileEnc() and write + // the shared key to the recipient's Pod (copySharedKey). If inheritKeyFrom + // were used here, the key would be stored under the directory URL and + // checkFileEnc(fileUrl) would return false, silently skipping key sharing. await writePod( filePath, jsonContent, - encrypted: false, + encrypted: true, + createAcl: true, overwrite: true, - inheritKeyFrom: dirPath, ); return true; } catch (e) { diff --git a/lib/services/sharing/sharing_service.dart b/lib/services/sharing/sharing_service.dart index 31b50c3..65263d4 100644 --- a/lib/services/sharing/sharing_service.dart +++ b/lib/services/sharing/sharing_service.dart @@ -33,6 +33,20 @@ import 'package:geopod/models/external_place.dart'; import 'package:geopod/models/external_places_call_result.dart'; import 'package:geopod/models/place.dart'; +// ── In-memory result cache ───────────────────────────────────────────────── +// Avoids repeating all Pod network requests every time the sharing page is +// opened within a short period. +ExternalPlacesCallResult? _cachedResult; +DateTime? _cacheTime; +const _cacheTtl = Duration(minutes: 5); + +/// Invalidates the in-memory cache, forcing the next call to +/// [getExternalPlaceList] to re-fetch from the Pod. +void invalidateExternalPlaceCache() { + _cachedResult = null; + _cacheTime = null; +} + /// Reads the shared-resources permission log and returns external places /// whose URLs look like geopod place files (`/places/place_`). /// @@ -155,10 +169,24 @@ Future getExternalPlaceContent(ExternalPlace place) async { /// place files; all others are silently ignored. /// /// Skips entries whose access has been revoked (`permissionType == 'revoke'`). +/// +/// Results are cached in memory for [_cacheTtl] to avoid redundant Pod +/// requests when the sharing page is reopened repeatedly. +/// Pass [forceRefresh] = true (or call [invalidateExternalPlaceCache]) to +/// bypass the cache. Future getExternalPlaceList({ bool hasCurrentAccess = true, + bool forceRefresh = false, }) async { + // Return cached result if still fresh. + if (!forceRefresh && + _cachedResult != null && + _cacheTime != null && + DateTime.now().difference(_cacheTime!) < _cacheTtl) { + debugPrint('[SharingService] Returning cached result (${_cachedResult!.places?.length ?? 0} places).'); + return _cachedResult!; + } final logMap = await scanPermLogFile(); if (logMap.isEmpty) return const ExternalPlacesCallResult(); @@ -199,9 +227,17 @@ Future getExternalPlaceList({ if (places.isEmpty) return const ExternalPlacesCallResult(); - // Fetch each place's content concurrently. - final futures = places.map(getExternalPlaceContent).toList(); - final results = await Future.wait(futures); + // Fetch each place's content with a bounded concurrency (5 at a time) to + // avoid overwhelming the Pod server or the device's network stack. + const _concurrentBatchSize = 5; + final List results = []; + for (var i = 0; i < places.length; i += _concurrentBatchSize) { + final batch = places.sublist( + i, + (i + _concurrentBatchSize).clamp(0, places.length), + ); + results.addAll(await Future.wait(batch.map(getExternalPlaceContent))); + } final List fullPlaces = []; final List nonExistentPlaces = []; @@ -232,13 +268,19 @@ Future getExternalPlaceList({ '${unparseablePlaces.length} unparseable.', ); - return ExternalPlacesCallResult( + final callResult = ExternalPlacesCallResult( places: fullPlaces, nonExistentPlaces: nonExistentPlaces, forbiddenPlaces: forbiddenPlaces, encryptionErrorPlaces: encryptionErrorPlaces, unparseablePlaces: unparseablePlaces, ); + + // Update cache. + _cachedResult = callResult; + _cacheTime = DateTime.now(); + + return callResult; } /// Status codes recycled from solidpod for internal use. diff --git a/lib/widgets/sharing/edit_external_place.dart b/lib/widgets/sharing/edit_external_place.dart new file mode 100644 index 0000000..14299ce --- /dev/null +++ b/lib/widgets/sharing/edit_external_place.dart @@ -0,0 +1,272 @@ +/// Widget for editing an externally owned place shared with the current user. +/// +// Time-stamp: <2026-04-13 Copilot> +/// +/// Copyright (C) 2025, Software Innovation Institute, ANU. +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"). +/// +/// License: https://opensource.org/license/gpl-3-0. + +// ignore_for_file: use_build_context_synchronously + +library; + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:geopod/models/external_place.dart'; +import 'package:geopod/services/sharing/sharing_service.dart'; + +/// A form widget for editing the note/title of an external place. +/// +/// Requires `write` permission on the shared place. Coordinates are +/// displayed as read-only; only the note field is editable as it is the +/// semantically meaningful content a recipient would update. + +class EditExternalPlace extends StatefulWidget { + const EditExternalPlace({ + super.key, + required this.place, + required this.backPage, + }); + + /// The shared place being edited. + final FoundExternalPlace place; + + /// Widget to return to after saving or cancelling. + final Widget backPage; + + @override + State createState() => _EditExternalPlaceState(); +} + +class _EditExternalPlaceState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _noteController; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _noteController = TextEditingController( + text: widget.place.content?.note ?? '', + ); + } + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isSaving = true); + + try { + final original = widget.place.content!; + final updated = original.copyWith(note: _noteController.text.trim()); + + await writeExternalPod( + widget.place.placeUrl, + jsonEncode(updated.toJson()), + widget.place.placeOwner, + ); + + // Invalidate the in-memory cache so the list reloads fresh. + invalidateExternalPlaceCache(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Place updated successfully.')), + ); + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => widget.backPage), + ); + } + } catch (e) { + debugPrint('[EditExternalPlace] save error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save: $e'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final content = widget.place.content; + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Edit Shared Place', + style: TextStyle(fontWeight: FontWeight.bold), + ), + leading: BackButton( + onPressed: () => Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => widget.backPage), + ), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Read-only info ────────────────────────────────────── + if (content != null) ...[ + Card( + color: Colors.grey.shade50, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Place Info (read-only)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey.shade600, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 8), + _InfoRow( + icon: Icons.location_on_outlined, + label: 'Coordinates', + value: content.coordinates, + ), + if (content.address != null) + _InfoRow( + icon: Icons.home_outlined, + label: 'Address', + value: content.address!, + ), + _InfoRow( + icon: Icons.person_outline, + label: 'Owner', + value: widget.place.placeOwner, + ), + ], + ), + ), + ), + const SizedBox(height: 20), + ], + + // ── Editable note field ───────────────────────────────── + TextFormField( + controller: _noteController, + decoration: const InputDecoration( + labelText: 'Note / Title', + hintText: 'Describe this place…', + prefixIcon: Icon(Icons.notes), + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 5, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + enabled: !_isSaving, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Note is required' : null, + ), + + const SizedBox(height: 24), + + // ── Action buttons ────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.cancel_outlined), + label: const Text('Cancel'), + onPressed: _isSaving + ? null + : () => Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => widget.backPage, + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.save), + label: const Text('Save'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + ), + onPressed: _isSaving ? null : _save, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +/// A single read-only info row used within the info card. + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 15, color: Colors.grey.shade600), + const SizedBox(width: 6), + Text( + '$label: ', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 13)), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/sharing/list_external_places.dart b/lib/widgets/sharing/list_external_places.dart index 5577b8c..79bfb4e 100644 --- a/lib/widgets/sharing/list_external_places.dart +++ b/lib/widgets/sharing/list_external_places.dart @@ -10,6 +10,8 @@ library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:geopod/models/external_place.dart'; @@ -37,20 +39,24 @@ class ListExternalPlaces extends StatefulWidget { } class _ListExternalPlacesState extends State { + late List _allPlaces; late List _foundPlaces; final TextEditingController _searchController = TextEditingController(); bool _sortNameAscending = true; bool _sortOwnerAscending = true; + Timer? _searchDebounce; @override void initState() { super.initState(); - _foundPlaces = widget.places.toListFoundExternalPlace(); + _allPlaces = widget.places.toListFoundExternalPlace(); + _foundPlaces = List.of(_allPlaces); _sortByName(true); } @override void dispose() { + _searchDebounce?.cancel(); _searchController.dispose(); super.dispose(); } @@ -78,20 +84,24 @@ class _ListExternalPlacesState extends State { } void _searchPlaces(String keyword) { - final all = widget.places.toListFoundExternalPlace(); - setState(() { - if (keyword.isEmpty) { - _foundPlaces = all; - } else { - final kw = keyword.toLowerCase(); - _foundPlaces = all.where((p) { - final name = (p.content?.displayTitle ?? p.placeFileName).toLowerCase(); - return name.contains(kw) || - p.placeOwner.toLowerCase().contains(kw) || - p.permissionGranter.toLowerCase().contains(kw) || - p.permissionList.toLowerCase().contains(kw); - }).toList(); - } + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 250), () { + if (!mounted) return; + setState(() { + if (keyword.isEmpty) { + _foundPlaces = List.of(_allPlaces); + } else { + final kw = keyword.toLowerCase(); + _foundPlaces = _allPlaces.where((p) { + final name = + (p.content?.displayTitle ?? p.placeFileName).toLowerCase(); + return name.contains(kw) || + p.placeOwner.toLowerCase().contains(kw) || + p.permissionGranter.toLowerCase().contains(kw) || + p.permissionList.toLowerCase().contains(kw); + }).toList(); + } + }); }); } diff --git a/lib/widgets/sharing/list_external_places_screen.dart b/lib/widgets/sharing/list_external_places_screen.dart index f70e996..b6ecd5d 100644 --- a/lib/widgets/sharing/list_external_places_screen.dart +++ b/lib/widgets/sharing/list_external_places_screen.dart @@ -13,7 +13,9 @@ library; import 'package:flutter/material.dart'; import 'package:geopod/models/external_places_call_result.dart'; +import 'package:geopod/services/places/encrypted_places_service.dart'; import 'package:geopod/services/sharing/sharing_service.dart'; +import 'package:geopod/widgets/encryption/security_key_dialog.dart'; import 'package:geopod/widgets/sharing/list_external_places.dart'; /// A [StatefulWidget] that fetches external-place data in a [FutureBuilder] @@ -38,7 +40,9 @@ class _ListExternalPlacesScreenState extends State { void _reload() { setState(() { - _dataFuture = getExternalPlaceList(); + // Force-refresh bypasses the in-memory TTL cache so the user always + // gets the latest data when they explicitly request a reload. + _dataFuture = getExternalPlaceList(forceRefresh: true); }); } @@ -76,6 +80,21 @@ class _ListExternalPlacesScreenState extends State { final encryptionErrors = result.encryptionErrorPlaces ?? []; final unparseable = result.unparseablePlaces ?? []; + // If encrypted places failed to load due to missing security + // key, auto-prompt the user once and then reload. + if (encryptionErrors.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + final hasKey = + await EncryptedPlacesService.isSecurityKeyAvailable(); + if (!mounted || hasKey) return; + final got = await showSecurityKeyDialog(context); + if (got && mounted) { + _reload(); + } + }); + } + // Notify about places whose source files have been deleted. if (nonExistent.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/widgets/sharing/view_external_place.dart b/lib/widgets/sharing/view_external_place.dart index 4565a57..2929fcf 100644 --- a/lib/widgets/sharing/view_external_place.dart +++ b/lib/widgets/sharing/view_external_place.dart @@ -13,6 +13,7 @@ library; import 'package:flutter/material.dart'; import 'package:geopod/models/external_place.dart'; +import 'package:geopod/widgets/sharing/edit_external_place.dart'; import 'package:geopod/widgets/sharing/share_external_place.dart'; /// A widget that shows the full details of an external place and provides @@ -138,6 +139,31 @@ class ViewExternalPlace extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + // Edit button (only if current user has write permission) + if (accessModes.contains('write')) ...[ + ElevatedButton.icon( + icon: const Icon(Icons.edit), + label: const Text('Edit'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + ), + onPressed: content == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditExternalPlace( + place: place, + backPage: ViewExternalPlace( + place: place, + listPage: listPage, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + ], // Re-share button (only if current user has control) if (accessModes.contains('control')) ...[ ElevatedButton.icon( From cabe73cd95dc0167c4282f064fadc89a193bcdf3 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Mon, 13 Apr 2026 11:34:54 +1000 Subject: [PATCH 3/9] fix one bug in cache management --- lib/models/external_place.dart | 2 +- lib/models/external_places_call_result.dart | 2 +- lib/services/sharing/sharing_service.dart | 8 ++++---- lib/widgets/map/geomap_place_handlers.dart | 5 +++++ .../media/video_player_fullscreen_page.dart | 2 +- .../media/video_player_speed_button.dart | 2 +- lib/widgets/sharing/edit_external_place.dart | 17 ++++------------- lib/widgets/sharing/list_external_places.dart | 2 +- .../sharing/list_external_places_screen.dart | 4 ++-- lib/widgets/sharing/share_external_place.dart | 2 +- lib/widgets/sharing/share_place.dart | 2 +- lib/widgets/sharing/view_external_place.dart | 2 +- 12 files changed, 23 insertions(+), 27 deletions(-) diff --git a/lib/models/external_place.dart b/lib/models/external_place.dart index f5c6371..3b8bdd6 100644 --- a/lib/models/external_place.dart +++ b/lib/models/external_place.dart @@ -1,6 +1,6 @@ /// Data model for an externally owned place shared with the user. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// diff --git a/lib/models/external_places_call_result.dart b/lib/models/external_places_call_result.dart index a23dfc4..69ad12b 100644 --- a/lib/models/external_places_call_result.dart +++ b/lib/models/external_places_call_result.dart @@ -1,6 +1,6 @@ /// Result model for external places future call. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// diff --git a/lib/services/sharing/sharing_service.dart b/lib/services/sharing/sharing_service.dart index 65263d4..acd4075 100644 --- a/lib/services/sharing/sharing_service.dart +++ b/lib/services/sharing/sharing_service.dart @@ -1,6 +1,6 @@ /// Sharing service: fetches external places shared with the user. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// @@ -229,12 +229,12 @@ Future getExternalPlaceList({ // Fetch each place's content with a bounded concurrency (5 at a time) to // avoid overwhelming the Pod server or the device's network stack. - const _concurrentBatchSize = 5; + const concurrentBatchSize = 5; final List results = []; - for (var i = 0; i < places.length; i += _concurrentBatchSize) { + for (var i = 0; i < places.length; i += concurrentBatchSize) { final batch = places.sublist( i, - (i + _concurrentBatchSize).clamp(0, places.length), + (i + concurrentBatchSize).clamp(0, places.length), ); results.addAll(await Future.wait(batch.map(getExternalPlaceContent))); } diff --git a/lib/widgets/map/geomap_place_handlers.dart b/lib/widgets/map/geomap_place_handlers.dart index c79ae07..99b48f0 100644 --- a/lib/widgets/map/geomap_place_handlers.dart +++ b/lib/widgets/map/geomap_place_handlers.dart @@ -48,6 +48,11 @@ void handleOptimisticPlaceSave({ savingPlaceIds.add(placeToSave.id); }); + // Update the cache immediately so that if placesChangeNotifier fires + // during the background save, onPlacesChanged() reads the already-updated + // list instead of the stale pre-insert cache. + PlacesCacheManager().cacheAllPlaces(allPlaces); + // Show snackbar after frame to avoid jank. SchedulerBinding.instance.addPostFrameCallback((_) { diff --git a/lib/widgets/media/video_player_fullscreen_page.dart b/lib/widgets/media/video_player_fullscreen_page.dart index c72ffff..2ee87f7 100644 --- a/lib/widgets/media/video_player_fullscreen_page.dart +++ b/lib/widgets/media/video_player_fullscreen_page.dart @@ -1,6 +1,6 @@ /// Fullscreen video page (part of video_player_widget.dart). /// -// Time-stamp: <2026-02-28 GitHub Copilot> +// Time-stamp: <2026-02-28 GitHub Miduo> /// /// Copyright (C) 2026, Software Innovation Institute, ANU. /// diff --git a/lib/widgets/media/video_player_speed_button.dart b/lib/widgets/media/video_player_speed_button.dart index 1d0326d..c28a1ea 100644 --- a/lib/widgets/media/video_player_speed_button.dart +++ b/lib/widgets/media/video_player_speed_button.dart @@ -1,6 +1,6 @@ /// Speed selector popup button (part of video_player_widget.dart). /// -// Time-stamp: <2026-02-28 GitHub Copilot> +// Time-stamp: <2026-02-28 GitHub Miduo> /// /// Copyright (C) 2026, Software Innovation Institute, ANU. /// diff --git a/lib/widgets/sharing/edit_external_place.dart b/lib/widgets/sharing/edit_external_place.dart index 14299ce..c46b431 100644 --- a/lib/widgets/sharing/edit_external_place.dart +++ b/lib/widgets/sharing/edit_external_place.dart @@ -1,6 +1,6 @@ /// Widget for editing an externally owned place shared with the current user. /// -// Time-stamp: <2026-04-13 Copilot> +// Time-stamp: <2026-04-13 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// @@ -15,7 +15,6 @@ library; import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:solidpod/solidpod.dart'; @@ -85,9 +84,7 @@ class _EditExternalPlaceState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Place updated successfully.')), ); - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => widget.backPage), - ); + Navigator.of(context).pop(); } } catch (e) { debugPrint('[EditExternalPlace] save error: $e'); @@ -115,9 +112,7 @@ class _EditExternalPlaceState extends State { style: TextStyle(fontWeight: FontWeight.bold), ), leading: BackButton( - onPressed: () => Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => widget.backPage), - ), + onPressed: () => Navigator.of(context).pop(), ), ), body: SafeArea( @@ -200,11 +195,7 @@ class _EditExternalPlaceState extends State { label: const Text('Cancel'), onPressed: _isSaving ? null - : () => Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => widget.backPage, - ), - ), + : () => Navigator.of(context).pop(), ), const SizedBox(width: 12), ElevatedButton.icon( diff --git a/lib/widgets/sharing/list_external_places.dart b/lib/widgets/sharing/list_external_places.dart index 79bfb4e..f6cf71a 100644 --- a/lib/widgets/sharing/list_external_places.dart +++ b/lib/widgets/sharing/list_external_places.dart @@ -1,6 +1,6 @@ /// Widget that lists external places shared with the current user. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// diff --git a/lib/widgets/sharing/list_external_places_screen.dart b/lib/widgets/sharing/list_external_places_screen.dart index b6ecd5d..a7fa6e6 100644 --- a/lib/widgets/sharing/list_external_places_screen.dart +++ b/lib/widgets/sharing/list_external_places_screen.dart @@ -1,6 +1,6 @@ /// Screen that asynchronously loads and displays external places. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// @@ -88,7 +88,7 @@ class _ListExternalPlacesScreenState extends State { final hasKey = await EncryptedPlacesService.isSecurityKeyAvailable(); if (!mounted || hasKey) return; - final got = await showSecurityKeyDialog(context); + final got = await showSecurityKeyDialog(this.context); if (got && mounted) { _reload(); } diff --git a/lib/widgets/sharing/share_external_place.dart b/lib/widgets/sharing/share_external_place.dart index 5caa843..8ccf0a4 100644 --- a/lib/widgets/sharing/share_external_place.dart +++ b/lib/widgets/sharing/share_external_place.dart @@ -1,6 +1,6 @@ /// Widget for re-sharing an externally owned place. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// diff --git a/lib/widgets/sharing/share_place.dart b/lib/widgets/sharing/share_place.dart index 342a50c..a317fdc 100644 --- a/lib/widgets/sharing/share_place.dart +++ b/lib/widgets/sharing/share_place.dart @@ -1,6 +1,6 @@ /// Widget for sharing a place owned by the current user. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// diff --git a/lib/widgets/sharing/view_external_place.dart b/lib/widgets/sharing/view_external_place.dart index 2929fcf..a295612 100644 --- a/lib/widgets/sharing/view_external_place.dart +++ b/lib/widgets/sharing/view_external_place.dart @@ -1,6 +1,6 @@ /// Widget for viewing the details of an external place. /// -// Time-stamp: <2026-04-08 Copilot> +// Time-stamp: <2026-04-08 Miduo> /// /// Copyright (C) 2025, Software Innovation Institute, ANU. /// From cdc78e340d84ccb46107b79ab764ead89f4f980a Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Mon, 13 Apr 2026 12:52:22 +1000 Subject: [PATCH 4/9] lint --- lib/services/places/places_fetch_service.dart | 10 +---- lib/services/places/places_pod_file.dart | 5 ++- lib/services/sharing/sharing_service.dart | 27 ++++++------- lib/widgets/locations_page.dart | 12 +++--- lib/widgets/map/geomap_place_handlers.dart | 5 --- lib/widgets/sharing/edit_external_place.dart | 13 +++---- lib/widgets/sharing/list_external_places.dart | 39 ++++++++++--------- .../sharing/list_external_places_screen.dart | 6 +-- lib/widgets/sharing/share_external_place.dart | 9 +---- lib/widgets/sharing/share_place.dart | 17 +++----- lib/widgets/sharing/view_external_place.dart | 28 ++++++------- 11 files changed, 70 insertions(+), 101 deletions(-) diff --git a/lib/services/places/places_fetch_service.dart b/lib/services/places/places_fetch_service.dart index 0bf8347..3bb9ced 100644 --- a/lib/services/places/places_fetch_service.dart +++ b/lib/services/places/places_fetch_service.dart @@ -25,7 +25,6 @@ library; -import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -198,11 +197,7 @@ class PlacesFetchService { final places = []; try { final content = await readPlacesJsonFile(); - if (content == null || content.trim().isEmpty) { - // Pod has no places.json yet — auto-create an empty one. - unawaited(writePlacesJsonFile('[]')); - return places; - } + if (content == null || content.trim().isEmpty) return places; final decoded = jsonDecode(content); if (decoded is List) { for (final i in decoded) { @@ -231,9 +226,6 @@ class PlacesFetchService { final c = await readPlacesJsonFile(); if (c != null && c.trim().isNotEmpty) { await PlacesCachePersistence.cachePodPlaces(c); - } else { - // Pod returned nothing — auto-create empty places.json. - unawaited(writePlacesJsonFile('[]')); } } catch (_) {} }); diff --git a/lib/services/places/places_pod_file.dart b/lib/services/places/places_pod_file.dart index 6ac8098..989cf9f 100644 --- a/lib/services/places/places_pod_file.dart +++ b/lib/services/places/places_pod_file.dart @@ -97,7 +97,10 @@ Future writePlacesJsonFile(String content) async { /// - A `.acl` file is automatically created when it doesn't exist yet, /// which is required before the file can be shared via [GrantPermissionUi]. -Future writeIndividualPlaceFile(Place place, {bool createAcl = true}) async { +Future writeIndividualPlaceFile( + Place place, { + bool createAcl = true, +}) async { try { await writePod( 'places/place_${place.id}.json', diff --git a/lib/services/sharing/sharing_service.dart b/lib/services/sharing/sharing_service.dart index acd4075..fba6ac9 100644 --- a/lib/services/sharing/sharing_service.dart +++ b/lib/services/sharing/sharing_service.dart @@ -94,13 +94,15 @@ ExternalPlace? extPlaceDetailsFromLog({ placeOwner = value; } else if (predicate.contains(PermissionLogLiteral.granter.toString())) { permissionGranter = value; - } else if (predicate - .contains(PermissionLogLiteral.recepient.toString())) { + } else if (predicate.contains( + PermissionLogLiteral.recepient.toString(), + )) { permissionRecepient = value; } else if (predicate.contains(PermissionLogLiteral.type.toString())) { permissionType = value; - } else if (predicate - .contains(PermissionLogLiteral.permissions.toString())) { + } else if (predicate.contains( + PermissionLogLiteral.permissions.toString(), + )) { permissionList = value; } } @@ -126,10 +128,7 @@ ExternalPlace? extPlaceDetailsFromLog({ Future getExternalPlaceContent(ExternalPlace place) async { try { - final raw = await readPod( - place.placeUrl, - pathType: PathType.absoluteUrl, - ); + final raw = await readPod(place.placeUrl, pathType: PathType.absoluteUrl); // If readPod returned raw encrypted TTL (decryption key missing on // recipient's pod — shared-keys.ttl absent or key not found), detect it @@ -184,7 +183,9 @@ Future getExternalPlaceList({ _cachedResult != null && _cacheTime != null && DateTime.now().difference(_cacheTime!) < _cacheTtl) { - debugPrint('[SharingService] Returning cached result (${_cachedResult!.places?.length ?? 0} places).'); + debugPrint( + '[SharingService] Returning cached result (${_cachedResult!.places?.length ?? 0} places).', + ); return _cachedResult!; } final logMap = await scanPermLogFile(); @@ -201,12 +202,10 @@ Future getExternalPlaceList({ final isEncPlace = urlStr.contains('/encrypted_data/enc_place_'); if (!isPlainPlace && !isEncPlace) continue; - final logRecord = - logMap[fileUrl] as Map; + final logRecord = logMap[fileUrl] as Map; // Skip revoked entries if caller only wants current access. - if (hasCurrentAccess && - logRecord[PermissionLogLiteral.type] == 'revoke') { + if (hasCurrentAccess && logRecord[PermissionLogLiteral.type] == 'revoke') { continue; } @@ -289,9 +288,11 @@ enum FileCallStatus { fail, fileNotExists, accessForbidden, + /// The resource is an encrypted TTL file but the decryption key is not /// available on this user's pod (shared-keys.ttl absent or key not found). decryptionKeyMissing, + /// The security key has not been set / cached yet when the read was /// attempted. Refreshing after the key is ready should resolve this. securityKeyNotAvailable, diff --git a/lib/widgets/locations_page.dart b/lib/widgets/locations_page.dart index 3a05e4b..e0eb701 100644 --- a/lib/widgets/locations_page.dart +++ b/lib/widgets/locations_page.dart @@ -447,13 +447,13 @@ class _LocationsPageState extends State // encryption key with the recipient. onShare: isLoggedIn && !p.isLocal ? () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SharePlace( - place: p, - backPage: const LocationsPage(), - ), + MaterialPageRoute( + builder: (_) => SharePlace( + place: p, + backPage: const LocationsPage(), ), - ) + ), + ) : null, ); }, diff --git a/lib/widgets/map/geomap_place_handlers.dart b/lib/widgets/map/geomap_place_handlers.dart index 99b48f0..c79ae07 100644 --- a/lib/widgets/map/geomap_place_handlers.dart +++ b/lib/widgets/map/geomap_place_handlers.dart @@ -48,11 +48,6 @@ void handleOptimisticPlaceSave({ savingPlaceIds.add(placeToSave.id); }); - // Update the cache immediately so that if placesChangeNotifier fires - // during the background save, onPlacesChanged() reads the already-updated - // list instead of the stale pre-insert cache. - PlacesCacheManager().cacheAllPlaces(allPlaces); - // Show snackbar after frame to avoid jank. SchedulerBinding.instance.addPostFrameCallback((_) { diff --git a/lib/widgets/sharing/edit_external_place.dart b/lib/widgets/sharing/edit_external_place.dart index c46b431..5955902 100644 --- a/lib/widgets/sharing/edit_external_place.dart +++ b/lib/widgets/sharing/edit_external_place.dart @@ -111,9 +111,7 @@ class _EditExternalPlaceState extends State { 'Edit Shared Place', style: TextStyle(fontWeight: FontWeight.bold), ), - leading: BackButton( - onPressed: () => Navigator.of(context).pop(), - ), + leading: BackButton(onPressed: () => Navigator.of(context).pop()), ), body: SafeArea( child: Padding( @@ -180,8 +178,9 @@ class _EditExternalPlaceState extends State { keyboardType: TextInputType.multiline, textInputAction: TextInputAction.newline, enabled: !_isSaving, - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Note is required' : null, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Note is required' + : null, ), const SizedBox(height: 24), @@ -253,9 +252,7 @@ class _InfoRow extends StatelessWidget { '$label: ', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), ), - Expanded( - child: Text(value, style: const TextStyle(fontSize: 13)), - ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 13))), ], ), ); diff --git a/lib/widgets/sharing/list_external_places.dart b/lib/widgets/sharing/list_external_places.dart index f6cf71a..d283b67 100644 --- a/lib/widgets/sharing/list_external_places.dart +++ b/lib/widgets/sharing/list_external_places.dart @@ -77,9 +77,11 @@ class _ListExternalPlacesState extends State { void _sortByOwner(bool ascending) { setState(() { _sortOwnerAscending = ascending; - _foundPlaces.sort((a, b) => ascending - ? a.placeOwner.toLowerCase().compareTo(b.placeOwner.toLowerCase()) - : b.placeOwner.toLowerCase().compareTo(a.placeOwner.toLowerCase())); + _foundPlaces.sort( + (a, b) => ascending + ? a.placeOwner.toLowerCase().compareTo(b.placeOwner.toLowerCase()) + : b.placeOwner.toLowerCase().compareTo(a.placeOwner.toLowerCase()), + ); }); } @@ -93,8 +95,8 @@ class _ListExternalPlacesState extends State { } else { final kw = keyword.toLowerCase(); _foundPlaces = _allPlaces.where((p) { - final name = - (p.content?.displayTitle ?? p.placeFileName).toLowerCase(); + final name = (p.content?.displayTitle ?? p.placeFileName) + .toLowerCase(); return name.contains(kw) || p.placeOwner.toLowerCase().contains(kw) || p.permissionGranter.toLowerCase().contains(kw) || @@ -226,10 +228,7 @@ class _ListExternalPlacesState extends State { /// A card tile for a single external place. class _ExternalPlaceCard extends StatelessWidget { - const _ExternalPlaceCard({ - required this.place, - required this.listPage, - }); + const _ExternalPlaceCard({required this.place, required this.listPage}); final FoundExternalPlace place; final Widget listPage; @@ -260,7 +259,11 @@ class _ExternalPlaceCard extends StatelessWidget { const SizedBox(height: 2), Row( children: [ - Icon(Icons.home_outlined, size: 13, color: Colors.blue.shade600), + Icon( + Icons.home_outlined, + size: 13, + color: Colors.blue.shade600, + ), const SizedBox(width: 4), Expanded( child: Text( @@ -279,15 +282,16 @@ class _ExternalPlaceCard extends StatelessWidget { const SizedBox(height: 2), Row( children: [ - Icon(Icons.person_outline, size: 13, color: Colors.grey.shade500), + Icon( + Icons.person_outline, + size: 13, + color: Colors.grey.shade500, + ), const SizedBox(width: 4), Expanded( child: Text( place.placeOwner, - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - ), + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -316,10 +320,7 @@ class _ExternalPlaceCard extends StatelessWidget { isThreeLine: true, onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ViewExternalPlace( - place: place, - listPage: listPage, - ), + builder: (_) => ViewExternalPlace(place: place, listPage: listPage), ), ), ), diff --git a/lib/widgets/sharing/list_external_places_screen.dart b/lib/widgets/sharing/list_external_places_screen.dart index a7fa6e6..f4bb4d3 100644 --- a/lib/widgets/sharing/list_external_places_screen.dart +++ b/lib/widgets/sharing/list_external_places_screen.dart @@ -280,11 +280,7 @@ class _ErrorView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red.shade400, - ), + Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), const SizedBox(height: 16), const Text( 'Failed to Load Shared Places', diff --git a/lib/widgets/sharing/share_external_place.dart b/lib/widgets/sharing/share_external_place.dart index 8ccf0a4..351e560 100644 --- a/lib/widgets/sharing/share_external_place.dart +++ b/lib/widgets/sharing/share_external_place.dart @@ -41,9 +41,7 @@ class ShareExternalPlace extends StatelessWidget { 'Re-Share Place', style: TextStyle(fontWeight: FontWeight.bold), ), - leading: BackButton( - onPressed: () => Navigator.of(context).pop(), - ), + leading: BackButton(onPressed: () => Navigator.of(context).pop()), ), body: SafeArea( child: Column( @@ -77,10 +75,7 @@ class ShareExternalPlace extends StatelessWidget { isExternalRes: true, ownerWebId: place.placeOwner, granterWebId: place.permissionGranter, - child: ShareExternalPlace( - place: place, - backPage: backPage, - ), + child: ShareExternalPlace(place: place, backPage: backPage), ), ), ], diff --git a/lib/widgets/sharing/share_place.dart b/lib/widgets/sharing/share_place.dart index a317fdc..2826287 100644 --- a/lib/widgets/sharing/share_place.dart +++ b/lib/widgets/sharing/share_place.dart @@ -25,11 +25,7 @@ import 'package:geopod/models/place.dart'; /// corresponding `.acl` file. class SharePlace extends StatelessWidget { - const SharePlace({ - super.key, - required this.place, - required this.backPage, - }); + const SharePlace({super.key, required this.place, required this.backPage}); /// The place to share. final Place place; @@ -58,9 +54,9 @@ class SharePlace extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold), ), leading: BackButton( - onPressed: () => Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => backPage), - ), + onPressed: () => Navigator.of( + context, + ).pushReplacement(MaterialPageRoute(builder: (_) => backPage)), ), ), body: SafeArea( @@ -90,10 +86,7 @@ class SharePlace extends StatelessWidget { child: GrantPermissionUi( showAppBar: false, resourceName: resourceName, - child: SharePlace( - place: place, - backPage: backPage, - ), + child: SharePlace(place: place, backPage: backPage), ), ), ], diff --git a/lib/widgets/sharing/view_external_place.dart b/lib/widgets/sharing/view_external_place.dart index a295612..04512d0 100644 --- a/lib/widgets/sharing/view_external_place.dart +++ b/lib/widgets/sharing/view_external_place.dart @@ -47,9 +47,7 @@ class ViewExternalPlace extends StatelessWidget { content?.displayTitle ?? place.placeFileName, style: const TextStyle(fontWeight: FontWeight.bold), ), - leading: BackButton( - onPressed: () => Navigator.of(context).pop(), - ), + leading: BackButton(onPressed: () => Navigator.of(context).pop()), ), body: SafeArea( child: SingleChildScrollView( @@ -95,7 +93,10 @@ class ViewExternalPlace extends StatelessWidget { padding: EdgeInsets.all(12), child: Row( children: [ - Icon(Icons.warning_amber_outlined, color: Colors.orange), + Icon( + Icons.warning_amber_outlined, + color: Colors.orange, + ), SizedBox(width: 8), Expanded( child: Text( @@ -151,16 +152,16 @@ class ViewExternalPlace extends StatelessWidget { onPressed: content == null ? null : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditExternalPlace( + MaterialPageRoute( + builder: (_) => EditExternalPlace( + place: place, + backPage: ViewExternalPlace( place: place, - backPage: ViewExternalPlace( - place: place, - listPage: listPage, - ), + listPage: listPage, ), ), ), + ), ), const SizedBox(width: 8), ], @@ -260,12 +261,7 @@ class _DetailRow extends StatelessWidget { ), ), ), - Expanded( - child: Text( - value, - style: const TextStyle(fontSize: 13), - ), - ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 13))), ], ), ); From ba32765e9d5427fa7479a5de8d9beeb39a0ab823 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Mon, 13 Apr 2026 13:12:48 +1000 Subject: [PATCH 5/9] fix bugs in cache management:encrypted place disappear after saving place --- lib/services/places/encrypted_places_service.dart | 13 +++++++++++++ lib/widgets/map/geomap_place_handlers.dart | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/services/places/encrypted_places_service.dart b/lib/services/places/encrypted_places_service.dart index 954eed8..faef845 100644 --- a/lib/services/places/encrypted_places_service.dart +++ b/lib/services/places/encrypted_places_service.dart @@ -26,6 +26,7 @@ import 'package:solidui/solidui.dart'; import 'package:geopod/models/place.dart'; import 'package:geopod/services/places/encrypted_places_io.dart'; +import 'package:geopod/services/places/places_cache_manager.dart'; import 'package:geopod/services/places_service.dart' show placesChangeNotifier; import 'package:geopod/services/pod/pod_directory_service.dart'; import 'package:geopod/widgets/encryption/security_key_dialog.dart'; @@ -293,6 +294,18 @@ class EncryptedPlacesService { _cachedEncryptedPlaces = places; + // Sync PlacesCacheManager so the next fetchPlaces() call (triggered by + // placesChangeNotifier below) returns the up-to-date merged list. + // Without this, when the cache already contains encrypted places, + // fetchPlaces hits the `else { return c; }` fast-path and returns the + // stale cache — causing newly saved encrypted places to disappear. + final cm = PlacesCacheManager(); + final current = cm.allPlaces; + if (current != null) { + final nonEncrypted = current.where((p) => !p.isEncrypted).toList(); + cm.cacheAllPlaces([...nonEncrypted, ...places]); + } + // Notify places change to trigger UI refresh. placesChangeNotifier.value++; diff --git a/lib/widgets/map/geomap_place_handlers.dart b/lib/widgets/map/geomap_place_handlers.dart index c79ae07..cef3f41 100644 --- a/lib/widgets/map/geomap_place_handlers.dart +++ b/lib/widgets/map/geomap_place_handlers.dart @@ -87,7 +87,12 @@ Future performPlaceBackgroundSave({ if (index != -1) allPlaces[index] = updatedPlace; savingPlaceIds.remove(originalPlace.id); }); - PlacesCacheManager().cacheAllPlaces(allPlaces); + // Surgically update only this place in the cache (address was resolved). + // Do NOT call cacheAllPlaces(allPlaces) here — that local `allPlaces` + // reference is captured at call time (before onPlacesChanged may have + // expanded it with previously-saved encrypted places), so overwriting + // the entire cache with it would silently drop those entries. + PlacesCacheManager().updatePlaceInCache(updatedPlace); showSaveSuccessSnackbar(context); }); } From edf89206be73f6b44eda62e541849752e0e6e3e9 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Tue, 14 Apr 2026 22:49:20 +1000 Subject: [PATCH 6/9] fix :bottom overflow --- lib/widgets/media/audio_player_widget.dart | 138 +++++++------- .../media/video_player_inline_controls.dart | 171 ++++++++++-------- 2 files changed, 168 insertions(+), 141 deletions(-) diff --git a/lib/widgets/media/audio_player_widget.dart b/lib/widgets/media/audio_player_widget.dart index d2dec6c..8dfe7fc 100644 --- a/lib/widgets/media/audio_player_widget.dart +++ b/lib/widgets/media/audio_player_widget.dart @@ -172,70 +172,84 @@ class _AudioPlayerWidgetState extends State { ), // ── Controls: time | [vol →] | ⏸ | [← empty] | time ──────────────── - Row( - children: [ - const SizedBox(width: 12), - Text(fmt(position), style: const TextStyle(fontSize: 12)), - const SizedBox(width: 8), - // Left half – volume, right-aligned to hug play button - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - iconSize: 18, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: isMuted ? 'Unmute' : 'Mute', - icon: Icon( - isMuted - ? Icons.volume_off - : _volume < 0.5 - ? Icons.volume_down - : Icons.volume_up, - size: 18, - ), - onPressed: _toggleMute, - ), - const SizedBox(width: 4), - SizedBox( - width: 80, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 10, + // Wrapped in LayoutBuilder to hide the fixed-width volume slider on + // narrow screens and prevent bottom overflow. + LayoutBuilder( + builder: (context, constraints) { + final showVolumeSlider = constraints.maxWidth >= 360; + final showVolumeIcon = constraints.maxWidth >= 280; + return Row( + children: [ + const SizedBox(width: 12), + Text(fmt(position), style: const TextStyle(fontSize: 12)), + const SizedBox(width: 8), + // Left half – volume, right-aligned to hug play button + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showVolumeIcon) ...[ + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: isMuted ? 'Unmute' : 'Mute', + icon: Icon( + isMuted + ? Icons.volume_off + : _volume < 0.5 + ? Icons.volume_down + : Icons.volume_up, + size: 18, + ), + onPressed: _toggleMute, ), - ), - child: Slider( - value: _volume, - min: 0.0, - max: 1.0, - divisions: 100, - label: '${(_volume * 100).round()}%', - onChanged: _setVolume, - ), - ), + if (showVolumeSlider) ...[ + const SizedBox(width: 4), + SizedBox( + width: 80, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 10, + ), + ), + child: Slider( + value: _volume, + min: 0.0, + max: 1.0, + divisions: 100, + label: '${(_volume * 100).round()}%', + onChanged: _setVolume, + ), + ), + ), + ], + const SizedBox(width: 12), + ], + ], ), - const SizedBox(width: 12), - ], - ), - ), - // Center – play/pause - IconButton( - iconSize: 40, - icon: Icon(isPlaying ? Icons.pause_circle : Icons.play_circle), - onPressed: isPlaying ? _controller.pause : _controller.play, - ), - // Right half – mirror spacer (keeps play centred) - const Expanded(child: SizedBox.shrink()), - const SizedBox(width: 8), - Text(fmt(duration), style: const TextStyle(fontSize: 12)), - const SizedBox(width: 12), - ], + ), + // Center – play/pause + IconButton( + iconSize: 40, + icon: Icon( + isPlaying ? Icons.pause_circle : Icons.play_circle, + ), + onPressed: isPlaying ? _controller.pause : _controller.play, + ), + // Right half – mirror spacer (keeps play centred) + const Expanded(child: SizedBox.shrink()), + const SizedBox(width: 8), + Text(fmt(duration), style: const TextStyle(fontSize: 12)), + const SizedBox(width: 12), + ], + ); + }, ), ], ); diff --git a/lib/widgets/media/video_player_inline_controls.dart b/lib/widgets/media/video_player_inline_controls.dart index 713c8c6..9ea87f5 100644 --- a/lib/widgets/media/video_player_inline_controls.dart +++ b/lib/widgets/media/video_player_inline_controls.dart @@ -105,91 +105,104 @@ class _InlineControlsState extends State<_InlineControls> { }, ), - // Controls row + // Controls row – wrapped in LayoutBuilder to hide the fixed-width + // volume slider on narrow screens and prevent bottom overflow. // Layout: [time] Expanded([vol ]) [play] Expanded([ speed]) [time] [fs?] - Row( - children: [ - const SizedBox(width: 12), - Text(fmt(position), style: const TextStyle(fontSize: 11)), - const SizedBox(width: 8), - // Left half volume right-aligned against play button - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - iconSize: 18, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: isMuted ? 'Unmute' : 'Mute', - icon: Icon( - isMuted - ? Icons.volume_off - : volume < 0.5 - ? Icons.volume_down - : Icons.volume_up, - size: 18, - ), - onPressed: widget.onToggleMute, - ), - const SizedBox(width: 4), - SizedBox( - width: 80, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 10, + LayoutBuilder( + builder: (context, constraints) { + final showVolumeSlider = constraints.maxWidth >= 360; + final showVolumeIcon = constraints.maxWidth >= 280; + return Row( + children: [ + const SizedBox(width: 12), + Text(fmt(position), style: const TextStyle(fontSize: 11)), + const SizedBox(width: 8), + // Left half volume right-aligned against play button + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showVolumeIcon) ...[ + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: isMuted ? 'Unmute' : 'Mute', + icon: Icon( + isMuted + ? Icons.volume_off + : volume < 0.5 + ? Icons.volume_down + : Icons.volume_up, + size: 18, + ), + onPressed: widget.onToggleMute, ), + if (showVolumeSlider) ...[ + const SizedBox(width: 4), + SizedBox( + width: 80, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 10, + ), + ), + child: Slider( + value: volume, + min: 0.0, + max: 1.0, + divisions: 100, + label: '${(volume * 100).round()}%', + onChanged: widget.onVolumeChanged, + ), + ), + ), + ], + const SizedBox(width: 12), + ], + ], + ), + ), + // Center play/pause + IconButton( + iconSize: 40, + icon: Icon( + isPlaying ? Icons.pause_circle : Icons.play_circle, + ), + onPressed: isPlaying ? ctrl.pause : ctrl.play, + ), + // Right half speed left-aligned against play button + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const SizedBox(width: 12), + _SpeedButton( + currentSpeed: speed, + onSpeedSelected: widget.onSpeedChanged, ), - child: Slider( - value: volume, - min: 0.0, - max: 1.0, - divisions: 100, - label: '${(volume * 100).round()}%', - onChanged: widget.onVolumeChanged, - ), - ), + ], ), - const SizedBox(width: 12), - ], - ), - ), - // Center play/pause - IconButton( - iconSize: 40, - icon: Icon(isPlaying ? Icons.pause_circle : Icons.play_circle), - onPressed: isPlaying ? ctrl.pause : ctrl.play, - ), - // Right half speed left-aligned against play button - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(width: 12), - _SpeedButton( - currentSpeed: speed, - onSpeedSelected: widget.onSpeedChanged, + ), + const SizedBox(width: 8), + Text(fmt(duration), style: const TextStyle(fontSize: 11)), + if (showFullscreen) ...[ + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.fullscreen), + tooltip: 'Fullscreen', + onPressed: widget.onFullscreen, ), ], - ), - ), - const SizedBox(width: 8), - Text(fmt(duration), style: const TextStyle(fontSize: 11)), - if (showFullscreen) ...[ - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.fullscreen), - tooltip: 'Fullscreen', - onPressed: widget.onFullscreen, - ), - ], - const SizedBox(width: 12), - ], + const SizedBox(width: 12), + ], + ); + }, ), ], ); From 2612820ee340b8be71e3313629d5818eadaf8e31 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Mon, 4 May 2026 14:21:21 +1000 Subject: [PATCH 7/9] update for new edition solidui --- lib/main.dart | 2 +- lib/services/places/places_import_export.dart | 2 +- lib/widgets/media/upload_media_dialog.dart | 2 +- lib/widgets/sharing/share_external_place.dart | 2 +- lib/widgets/sharing/share_place.dart | 2 +- lib/widgets/weather/pdf_export_handler.dart | 2 +- pubspec.yaml | 6 +++--- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 7ce80b1..04ad708 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,7 +28,7 @@ library; import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart' show KeyManager, setAppDirName; -import 'package:solidui/solidui.dart'; +import 'package:solidui/solidui.dart' hide isDesktop; import 'package:video_player_media_kit/video_player_media_kit.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/services/places/places_import_export.dart b/lib/services/places/places_import_export.dart index 38a7b67..2baefb9 100644 --- a/lib/services/places/places_import_export.dart +++ b/lib/services/places/places_import_export.dart @@ -105,7 +105,7 @@ class PlacesImportExport { final result = ImportResult(); try { - final pickResult = await FilePicker.platform.pickFiles( + final pickResult = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['json'], withData: true, diff --git a/lib/widgets/media/upload_media_dialog.dart b/lib/widgets/media/upload_media_dialog.dart index 5785946..5ceaf42 100644 --- a/lib/widgets/media/upload_media_dialog.dart +++ b/lib/widgets/media/upload_media_dialog.dart @@ -73,7 +73,7 @@ class _UploadMediaDialogState extends State<_UploadMediaDialog> { ? ['mp3', 'm4a', 'aac', 'ogg', 'wav', 'webm'] : ['mp4', 'mov', 'mkv', 'avi', 'webm']; - final result = await FilePicker.platform.pickFiles( + final result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: allowedExtensions, withData: true, // We need bytes for upload. diff --git a/lib/widgets/sharing/share_external_place.dart b/lib/widgets/sharing/share_external_place.dart index 351e560..d522a2c 100644 --- a/lib/widgets/sharing/share_external_place.dart +++ b/lib/widgets/sharing/share_external_place.dart @@ -71,7 +71,7 @@ class ShareExternalPlace extends StatelessWidget { // and isExternalRes must be true. child: GrantPermissionUi( showAppBar: false, - resourceName: place.placeUrl, + resourceNames: [place.placeUrl], isExternalRes: true, ownerWebId: place.placeOwner, granterWebId: place.permissionGranter, diff --git a/lib/widgets/sharing/share_place.dart b/lib/widgets/sharing/share_place.dart index 2826287..be99e0d 100644 --- a/lib/widgets/sharing/share_place.dart +++ b/lib/widgets/sharing/share_place.dart @@ -85,7 +85,7 @@ class SharePlace extends StatelessWidget { Expanded( child: GrantPermissionUi( showAppBar: false, - resourceName: resourceName, + resourceNames: [resourceName], child: SharePlace(place: place, backPage: backPage), ), ), diff --git a/lib/widgets/weather/pdf_export_handler.dart b/lib/widgets/weather/pdf_export_handler.dart index 249d7fc..dee4ea1 100644 --- a/lib/widgets/weather/pdf_export_handler.dart +++ b/lib/widgets/weather/pdf_export_handler.dart @@ -42,7 +42,7 @@ Future handlePdfExport(BuildContext context, Uint8List pdfBytes) async { } } else { // For mobile/desktop: Let user choose save location. - final outputPath = await FilePicker.platform.saveFile( + final outputPath = await FilePicker.saveFile( dialogTitle: 'Save PDF Report', fileName: '$filename.pdf', type: FileType.custom, diff --git a/pubspec.yaml b/pubspec.yaml index 08092da..4fbd055 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ environment: dependencies: cupertino_icons: ^1.0.8 - file_picker: ^10.3.10 + file_picker: ^11.0.2 file_saver: ^0.3.1 flutter: sdk: flutter @@ -29,8 +29,8 @@ dependencies: pdf: ^3.11.1 shared_preferences: ^2.3.3 solid_auth: ^0.1.28 - solidpod: ^0.12.2 - solidui: ^0.3.8 + solidpod: ^0.12.5 + solidui: ^0.3.35 url_launcher: ^6.3.1 uuid: ^4.5.1 web: ^1.1.0 From 1473242b01b464d356ca1803173bdb4576c62f15 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Mon, 4 May 2026 23:03:15 +1000 Subject: [PATCH 8/9] delete duplicate dependencies due to merge --- pubspec.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0a42ea1..a093bf5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,9 +20,6 @@ dependencies: file_saver: ^0.3.1 flutter: sdk: flutter - cupertino_icons: ^1.0.8 - file_picker: ^11.0.2 - file_saver: ^0.3.1 flutter_colorpicker: ^1.1.0 flutter_map: ^8.2.2 geolocator: ^14.0.2 From 4465ae3765f3c513a86414910546595032be25b0 Mon Sep 17 00:00:00 2001 From: Miduo666 Date: Tue, 5 May 2026 00:14:39 +1000 Subject: [PATCH 9/9] again. delete duplicate dependencies due to merge --- pubspec.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index a093bf5..7da728e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: http: ^1.2.2 intl: ^0.20.1 latlong2: ^0.9.1 - pdf: ^3.11.1 shared_preferences: ^2.3.3 solid_auth: ^0.1.28 solidpod: ^0.12.5 @@ -42,17 +41,8 @@ dependencies: media_kit_video: ^2.0.1 path_provider: ^2.1.5 pdf: ^3.11.1 - shared_preferences: ^2.3.3 - solid_auth: ^0.1.28 - solidpod: ^0.12.4 - solidui: ^0.3.24 - universal_io: any - url_launcher: ^6.3.1 - uuid: ^4.5.1 video_player: ^2.9.2 video_player_media_kit: ^2.0.0 - web: ^1.1.0 - window_manager: ^0.5.0 dev_dependencies: build_runner: ^2.4.7