Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/app_scaffold.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
123 changes: 123 additions & 0 deletions lib/models/external_place.dart
Original file line number Diff line number Diff line change
@@ -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 <https://opensource.org/license/gpl-3-0>.

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_<uuid>.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<ExternalPlace>] to a
/// [List<FoundExternalPlace>].
extension ExternalPlaceListExtension on List<ExternalPlace> {
List<FoundExternalPlace> toListFoundExternalPlace() =>
map((p) => p.toFound()).toList();
}
41 changes: 41 additions & 0 deletions lib/models/external_places_call_result.dart
Original file line number Diff line number Diff line change
@@ -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<ExternalPlace>? places;
final List<ExternalPlace>? nonExistentPlaces;
final List<ExternalPlace>? forbiddenPlaces;
final List<ExternalPlace>? encryptionErrorPlaces;
final List<ExternalPlace>? unparseablePlaces;

const ExternalPlacesCallResult({
this.places = const [],
this.nonExistentPlaces = const [],
this.forbiddenPlaces = const [],
this.encryptionErrorPlaces = const [],
this.unparseablePlaces = const [],
});
}
10 changes: 7 additions & 3 deletions lib/services/places/encrypted_places_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,19 @@ Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod(
Future<bool> 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) {
Expand Down
13 changes: 13 additions & 0 deletions lib/services/places/encrypted_places_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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++;
Expand Down
35 changes: 18 additions & 17 deletions lib/services/places/places_pod_file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,25 +91,26 @@ Future<bool> writePlacesJsonFile(String content) async {
}

/// Write an individual place file.

Future<bool> 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<bool> 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;
Expand Down
3 changes: 2 additions & 1 deletion lib/services/places/places_write_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
Loading
Loading