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/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/models/external_place.dart b/lib/models/external_place.dart new file mode 100644 index 0000000..3b8bdd6 --- /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 Miduo> +/// +/// 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..69ad12b --- /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 Miduo> +/// +/// 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/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/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/services/places/places_pod_file.dart b/lib/services/places/places_pod_file.dart index 13478b8..989cf9f 100644 --- a/lib/services/places/places_pod_file.dart +++ b/lib/services/places/places_pod_file.dart @@ -91,25 +91,26 @@ Future writePlacesJsonFile(String content) async { } /// Write an individual place file. - -Future writeIndividualPlaceFile(Place place) async { +/// +/// 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, { + 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..fba6ac9 --- /dev/null +++ b/lib/services/sharing/sharing_service.dart @@ -0,0 +1,300 @@ +/// Sharing service: fetches external places shared with the user. +/// +// Time-stamp: <2026-04-08 Miduo> +/// +/// 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'; + +// ── 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_`). +/// +/// 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'`). +/// +/// 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(); + + 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 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 = []; + 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.', + ); + + 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. +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..e0eb701 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/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); }); } 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_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 new file mode 100644 index 0000000..5955902 --- /dev/null +++ b/lib/widgets/sharing/edit_external_place.dart @@ -0,0 +1,260 @@ +/// Widget for editing an externally owned place shared with the current user. +/// +// Time-stamp: <2026-04-13 Miduo> +/// +/// 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: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).pop(); + } + } 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).pop()), + ), + 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).pop(), + ), + 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 new file mode 100644 index 0000000..d283b67 --- /dev/null +++ b/lib/widgets/sharing/list_external_places.dart @@ -0,0 +1,344 @@ +/// Widget that lists external places shared with the current user. +/// +// Time-stamp: <2026-04-08 Miduo> +/// +/// 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 'dart:async'; + +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 _allPlaces; + late List _foundPlaces; + final TextEditingController _searchController = TextEditingController(); + bool _sortNameAscending = true; + bool _sortOwnerAscending = true; + Timer? _searchDebounce; + + @override + void initState() { + super.initState(); + _allPlaces = widget.places.toListFoundExternalPlace(); + _foundPlaces = List.of(_allPlaces); + _sortByName(true); + } + + @override + void dispose() { + _searchDebounce?.cancel(); + _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) { + _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(); + } + }); + }); + } + + @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..f4bb4d3 --- /dev/null +++ b/lib/widgets/sharing/list_external_places_screen.dart @@ -0,0 +1,306 @@ +/// Screen that asynchronously loads and displays external places. +/// +// Time-stamp: <2026-04-08 Miduo> +/// +/// 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/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] +/// 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(() { + // 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); + }); + } + + @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 ?? []; + + // 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(this.context); + if (got && mounted) { + _reload(); + } + }); + } + + // 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..d522a2c --- /dev/null +++ b/lib/widgets/sharing/share_external_place.dart @@ -0,0 +1,86 @@ +/// Widget for re-sharing an externally owned place. +/// +// Time-stamp: <2026-04-08 Miduo> +/// +/// 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, + resourceNames: [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..be99e0d --- /dev/null +++ b/lib/widgets/sharing/share_place.dart @@ -0,0 +1,97 @@ +/// Widget for sharing a place owned by the current user. +/// +// Time-stamp: <2026-04-08 Miduo> +/// +/// 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, + resourceNames: [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..04512d0 --- /dev/null +++ b/lib/widgets/sharing/view_external_place.dart @@ -0,0 +1,269 @@ +/// Widget for viewing the details of an external place. +/// +// Time-stamp: <2026-04-08 Miduo> +/// +/// 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/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 +/// 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: [ + // 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( + 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))), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 10e4019..7da728e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,34 +15,34 @@ environment: # flutter pub outdated dependencies: - flutter: - sdk: flutter cupertino_icons: ^1.0.8 file_picker: ^11.0.2 file_saver: ^0.3.1 + flutter: + sdk: flutter flutter_colorpicker: ^1.1.0 flutter_map: ^8.2.2 geolocator: ^14.0.2 http: ^1.2.2 intl: ^0.20.1 latlong2: ^0.9.1 + shared_preferences: ^2.3.3 + solid_auth: ^0.1.28 + solidpod: ^0.12.5 + solidui: ^0.3.35 + url_launcher: ^6.3.1 + uuid: ^4.5.1 + web: ^1.1.0 + window_manager: ^0.5.0 + universal_io: any markdown_widget_builder: ^0.0.11 media_kit: ^1.2.6 media_kit_libs_video: ^1.0.7 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